memory_graph.py
15.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
"""
Memory Graph — SQLite-backed knowledge graph for the Agent Phone Runtime.
Schema:
person — unified identity across apps (WeChat, Telegram, contacts)
message — messages linked to persons
entity — general facts/events (calendar, notes, etc.)
relation — named edges between persons
Vector search is handled by chromadb (optional, falls back to FTS5 keyword search).
"""
from __future__ import annotations
import json
import os
import sqlite3
import time
from pathlib import Path
from typing import Any
def _load_hermes_env():
"""Load ~/.hermes/.env so KIMI_API_KEY etc. are always available."""
# On Android as root via ADB, HOME may be the Mac host path.
# Always prefer the Termux home if it exists.
termux_home = Path("/data/data/com.termux/files/home")
home = termux_home if termux_home.exists() else Path(os.environ.get("HOME", str(termux_home)))
env_file = home / ".hermes" / ".env"
if not env_file.exists():
return
for raw in env_file.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, _, v = line.partition("=")
k = k.strip()
v = v.strip().strip('"').strip("'")
if k and not os.environ.get(k):
os.environ[k] = v
_load_hermes_env()
_TERMUX_HOME = Path("/data/data/com.termux/files/home")
_HERMES_HOME = (_TERMUX_HOME if _TERMUX_HOME.exists()
else Path(os.environ.get("HOME", str(_TERMUX_HOME)))) / ".hermes"
DB_PATH = os.environ.get("MEMORY_GRAPH_DB", str(_HERMES_HOME / "memory_graph.db"))
def _get_db() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def init_db():
conn = _get_db()
conn.executescript("""
CREATE TABLE IF NOT EXISTS person (
id INTEGER PRIMARY KEY AUTOINCREMENT,
wxid TEXT,
tg_id TEXT,
phone TEXT,
nickname TEXT,
remark TEXT,
source_app TEXT,
created_at INTEGER DEFAULT (strftime('%s','now')),
updated_at INTEGER DEFAULT (strftime('%s','now')),
UNIQUE(wxid),
UNIQUE(tg_id)
);
CREATE TABLE IF NOT EXISTS message (
id INTEGER PRIMARY KEY AUTOINCREMENT,
person_id INTEGER REFERENCES person(id),
talker TEXT,
content TEXT,
is_sent INTEGER DEFAULT 0,
app TEXT DEFAULT 'wechat',
ts INTEGER,
raw_id TEXT,
created_at INTEGER DEFAULT (strftime('%s','now'))
);
CREATE TABLE IF NOT EXISTS entity (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT,
title TEXT,
content TEXT,
source_app TEXT,
ts INTEGER,
metadata TEXT,
created_at INTEGER DEFAULT (strftime('%s','now'))
);
CREATE TABLE IF NOT EXISTS relation (
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_id INTEGER REFERENCES person(id),
to_id INTEGER REFERENCES person(id),
label TEXT,
weight REAL DEFAULT 1.0,
created_at INTEGER DEFAULT (strftime('%s','now'))
);
CREATE VIRTUAL TABLE IF NOT EXISTS message_fts USING fts5(
content, talker, app,
content='message', content_rowid='id'
);
CREATE TRIGGER IF NOT EXISTS message_ai AFTER INSERT ON message BEGIN
INSERT INTO message_fts(rowid, content, talker, app)
VALUES (new.id, new.content, new.talker, new.app);
END;
CREATE INDEX IF NOT EXISTS idx_message_person ON message(person_id);
CREATE INDEX IF NOT EXISTS idx_message_ts ON message(ts);
CREATE INDEX IF NOT EXISTS idx_message_talker ON message(talker);
CREATE INDEX IF NOT EXISTS idx_entity_type ON entity(type);
CREATE INDEX IF NOT EXISTS idx_entity_ts ON entity(ts);
""")
conn.commit()
conn.close()
def upsert_person(wxid: str = None, tg_id: str = None, nickname: str = None,
remark: str = None, phone: str = None, source_app: str = "wechat") -> int:
conn = _get_db()
now = int(time.time())
try:
if wxid:
row = conn.execute("SELECT id FROM person WHERE wxid=?", (wxid,)).fetchone()
if row:
conn.execute(
"UPDATE person SET nickname=COALESCE(?,nickname), remark=COALESCE(?,remark), "
"phone=COALESCE(?,phone), updated_at=? WHERE id=?",
(nickname, remark, phone, now, row["id"])
)
conn.commit()
return row["id"]
cursor = conn.execute(
"INSERT OR IGNORE INTO person(wxid, tg_id, phone, nickname, remark, source_app, created_at, updated_at) "
"VALUES(?,?,?,?,?,?,?,?)",
(wxid, tg_id, phone, nickname, remark, source_app, now, now)
)
conn.commit()
pid = cursor.lastrowid
if pid == 0:
pid = conn.execute("SELECT id FROM person WHERE wxid=?", (wxid,)).fetchone()["id"]
return pid
finally:
conn.close()
def ingest_wechat_contacts(contacts: list[dict]) -> dict:
added = 0
updated = 0
for c in contacts:
wxid = c.get("username", "")
if not wxid:
continue
nickname = c.get("nickname") or c.get("conRemark") or wxid
remark = c.get("conRemark", "")
existing = _person_exists(wxid=wxid)
upsert_person(wxid=wxid, nickname=nickname, remark=remark, source_app="wechat")
if existing:
updated += 1
else:
added += 1
return {"added": added, "updated": updated, "total": len(contacts)}
def ingest_wechat_messages(messages: list[dict]) -> dict:
conn = _get_db()
added = 0
# Pre-build wxid→person_id cache from existing persons to avoid nested connections
person_cache: dict[str, int] = {}
for row in conn.execute("SELECT id, wxid FROM person WHERE wxid IS NOT NULL"):
person_cache[row["wxid"]] = row["id"]
try:
for m in messages:
talker = m.get("talker", "")
content = m.get("content", "")
ts = m.get("createTime", int(time.time() * 1000)) // 1000
raw_id = str(m.get("msgId", ""))
is_sent = m.get("isSend", 0)
if not talker or not content:
continue
# Find or create person (using same connection)
person_id = None
if not talker.endswith("@chatroom"):
if talker in person_cache:
person_id = person_cache[talker]
else:
now = int(time.time())
cursor = conn.execute(
"INSERT OR IGNORE INTO person(wxid, nickname, source_app, created_at, updated_at) "
"VALUES(?,?,?,?,?)",
(talker, talker, "wechat", now, now)
)
if cursor.lastrowid:
person_id = cursor.lastrowid
else:
row = conn.execute("SELECT id FROM person WHERE wxid=?", (talker,)).fetchone()
person_id = row["id"] if row else None
if person_id:
person_cache[talker] = person_id
# Dedup by raw_id
if not conn.execute("SELECT 1 FROM message WHERE raw_id=? AND app='wechat'", (raw_id,)).fetchone():
conn.execute(
"INSERT INTO message(person_id, talker, content, is_sent, app, ts, raw_id) "
"VALUES(?,?,?,?,?,?,?)",
(person_id, talker, content, is_sent, "wechat", ts, raw_id)
)
added += 1
conn.commit()
return {"added": added, "total": len(messages)}
finally:
conn.close()
def _person_exists(wxid: str = None, tg_id: str = None) -> bool:
conn = _get_db()
try:
if wxid:
return conn.execute("SELECT 1 FROM person WHERE wxid=?", (wxid,)).fetchone() is not None
if tg_id:
return conn.execute("SELECT 1 FROM person WHERE tg_id=?", (tg_id,)).fetchone() is not None
return False
finally:
conn.close()
def search_memory(query: str, person: str = "", since: int = 0,
until: int = 0, source_app: str = "", limit: int = 20) -> list[dict]:
conn = _get_db()
try:
results = []
# FTS5 keyword search
fts_query = query.replace('"', '""')
sql = """
SELECT m.id, m.talker, m.content, m.is_sent, m.app, m.ts,
p.nickname, p.remark
FROM message_fts fts
JOIN message m ON fts.rowid = m.id
LEFT JOIN person p ON m.person_id = p.id
WHERE message_fts MATCH ?
"""
params: list[Any] = [fts_query]
if person:
sql += " AND (m.talker LIKE ? OR p.nickname LIKE ? OR p.remark LIKE ?)"
params += [f"%{person}%", f"%{person}%", f"%{person}%"]
if since:
sql += " AND m.ts >= ?"
params.append(since)
if until:
sql += " AND m.ts <= ?"
params.append(until)
if source_app:
sql += " AND m.app = ?"
params.append(source_app)
sql += f" ORDER BY m.ts DESC LIMIT {limit}"
try:
rows = conn.execute(sql, params).fetchall()
for r in rows:
results.append(dict(r))
except sqlite3.OperationalError:
# FTS query syntax error — fall back to LIKE
like_sql = """
SELECT m.id, m.talker, m.content, m.is_sent, m.app, m.ts,
p.nickname, p.remark
FROM message m
LEFT JOIN person p ON m.person_id = p.id
WHERE m.content LIKE ?
"""
like_params: list[Any] = [f"%{query}%"]
if person:
like_sql += " AND (m.talker LIKE ? OR p.nickname LIKE ?)"
like_params += [f"%{person}%", f"%{person}%"]
if since:
like_sql += " AND m.ts >= ?"
like_params.append(since)
like_sql += f" ORDER BY m.ts DESC LIMIT {limit}"
rows = conn.execute(like_sql, like_params).fetchall()
for r in rows:
results.append(dict(r))
return results
finally:
conn.close()
def get_stats() -> dict:
conn = _get_db()
try:
persons = conn.execute("SELECT COUNT(*) FROM person").fetchone()[0]
messages = conn.execute("SELECT COUNT(*) FROM message").fetchone()[0]
entities = conn.execute("SELECT COUNT(*) FROM entity").fetchone()[0]
vectors = 0
try:
vectors = conn.execute("SELECT COUNT(*) FROM message_vector").fetchone()[0]
except Exception:
pass
return {"persons": persons, "messages": messages, "entities": entities, "vectors": vectors}
finally:
conn.close()
# ── Vector Memory (Sprint 9) ───────────────────────────────────────────────────
KIMI_API_KEY = os.environ.get("KIMI_API_KEY", "")
KIMI_BASE_URL = os.environ.get("KIMI_BASE_URL", "https://api.kimi.com/coding/v1")
EMBED_MODEL = "moonshot-v1-embedding"
EMBED_DIM = 1024
def _init_vector_table():
conn = _get_db()
conn.executescript("""
CREATE TABLE IF NOT EXISTS message_vector (
message_id INTEGER PRIMARY KEY REFERENCES message(id),
embedding BLOB
);
""")
conn.commit()
conn.close()
def _get_embedding(text: str) -> list[float] | None:
"""Call Kimi embedding API and return 1024-dim vector."""
if not KIMI_API_KEY:
return None
import urllib.request
text = text[:2000] # Kimi max input per item
payload = json.dumps({"model": EMBED_MODEL, "input": [text]}).encode()
req = urllib.request.Request(
f"{KIMI_BASE_URL}/embeddings",
data=payload,
headers={
"Authorization": f"Bearer {KIMI_API_KEY}",
"Content-Type": "application/json",
},
)
try:
import urllib.error
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode())
return data["data"][0]["embedding"]
except Exception:
return None
def _embed_to_blob(vec: list[float]) -> bytes:
import struct
return struct.pack(f"{len(vec)}f", *vec)
def _blob_to_embed(blob: bytes) -> list[float]:
import struct
n = len(blob) // 4
return list(struct.unpack(f"{n}f", blob))
def _cosine(a: list[float], b: list[float]) -> float:
dot = sum(x * y for x, y in zip(a, b))
na = sum(x * x for x in a) ** 0.5
nb = sum(x * x for x in b) ** 0.5
return dot / (na * nb + 1e-9)
def embed_messages(limit: int = 200) -> dict:
"""Generate and store embeddings for un-embedded messages."""
if not KIMI_API_KEY:
return {"error": "KIMI_API_KEY not set"}
_init_vector_table()
conn = _get_db()
try:
# Find messages without embeddings
rows = conn.execute(
"SELECT m.id, m.content FROM message m "
"LEFT JOIN message_vector mv ON m.id = mv.message_id "
"WHERE mv.message_id IS NULL AND m.content != '' "
f"LIMIT {limit}"
).fetchall()
embedded = 0
for row in rows:
mid, content = row["id"], row["content"]
vec = _get_embedding(content)
if vec:
blob = _embed_to_blob(vec)
conn.execute(
"INSERT OR REPLACE INTO message_vector(message_id, embedding) VALUES(?,?)",
(mid, blob)
)
embedded += 1
conn.commit()
return {"embedded": embedded, "pending": len(rows) - embedded}
finally:
conn.close()
def semantic_search(query: str, limit: int = 10) -> list[dict]:
"""Semantic similarity search using Kimi embeddings."""
if not KIMI_API_KEY:
return []
_init_vector_table()
query_vec = _get_embedding(query)
if not query_vec:
return []
conn = _get_db()
try:
rows = conn.execute(
"SELECT mv.message_id, mv.embedding, m.talker, m.content, m.ts, "
"p.nickname, p.remark "
"FROM message_vector mv "
"JOIN message m ON mv.message_id = m.id "
"LEFT JOIN person p ON m.person_id = p.id"
).fetchall()
scored = []
for r in rows:
vec = _blob_to_embed(r["embedding"])
score = _cosine(query_vec, vec)
scored.append({
"id": r["message_id"],
"talker": r["talker"],
"content": r["content"],
"ts": r["ts"],
"nickname": r["nickname"],
"remark": r["remark"],
"score": round(score, 4),
})
scored.sort(key=lambda x: x["score"], reverse=True)
return scored[:limit]
finally:
conn.close()
if __name__ == "__main__":
import os as _os
# Load .env if available
env_file = _os.path.expanduser("~/.hermes/.env")
if _os.path.exists(env_file):
for line in open(env_file):
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
_os.environ.setdefault(k.strip(), v.strip())
# Reload key after env load
import memory_graph as _self
_self.KIMI_API_KEY = _os.environ.get("KIMI_API_KEY", "")
init_db()
print("Memory Graph initialized:", DB_PATH)
print("Stats:", get_stats())