Add AI Council architecture: Tier 2/3/Graph implementation + Integration Plan

Architecture (Agent 1):
- hermes_memory/tier2/{schema,facts,entities,relations,timeline}.py
- hermes_memory/tier3/{backend,chroma_backend,embedder}.py
- hermes_memory/graph/nx_store.py
- hermes_memory/api/memory_api.py (unified API)
- hermes_memory/cron/{consolidate,embed_queue,graph_refresh,prune}.py
- hermes_memory/config.py + pyproject.toml

Integration Plan (Agent 3):
- INTEGRATION_PLAN.md: Memory Provider Plugin strategy
- Hermes Core needs minimal changes
- sync_turn() + prefetch() hooks
- Skills integration via nextlevel_search/remember

Auto-Extraction (Agent 2):
- ARCHITECTURE.md: Full extraction pipeline docs
- Chunking, Pre-Filter, LLM Prompts, Classification
- Entity-Linking, Temporal Reasoning, Deduplication

All files: Python syntax checked, ECC standards applied.
This commit is contained in:
Florian Hartmann
2026-06-03 22:51:50 +00:00
parent 33fb180855
commit b83546d833
25 changed files with 2661 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
"""Tier 2 — Structured Knowledge (SQLite)."""
from hermes_memory.tier2.facts import Fact, FactStore
from hermes_memory.tier2.entities import Entity, EntityStore
from hermes_memory.tier2.relations import Relation, RelationStore
from hermes_memory.tier2.timeline import TimelineEvent, TimelineStore
from hermes_memory.tier2.schema import init_schema, migrate
__all__ = [
"Fact",
"FactStore",
"Entity",
"EntityStore",
"Relation",
"RelationStore",
"TimelineEvent",
"TimelineStore",
"init_schema",
"migrate",
]
+108
View File
@@ -0,0 +1,108 @@
"""EntityStore — Entitäts-Verwaltung für Tier 2."""
import json
import sqlite3
import time
import uuid as uuid_mod
from dataclasses import dataclass
from typing import List, Optional
@dataclass
class Entity:
uuid: str
name: str
aliases: List[str]
entity_type: str
description: Optional[str]
first_seen: float
last_seen: float
occurrence_count: int
metadata: dict
class EntityStore:
def __init__(self, conn: sqlite3.Connection):
self.conn = conn
def ensure(
self,
name: str,
entity_type: str,
aliases: Optional[List[str]] = None,
description: Optional[str] = None,
metadata: Optional[dict] = None,
) -> Entity:
existing = self._find_by_name(name)
if existing:
# Aktualisiere last_seen und occurrence_count
self.conn.execute(
"UPDATE entities SET last_seen = ?, occurrence_count = occurrence_count + 1 WHERE uuid = ?",
(time.time(), existing.uuid),
)
self.conn.commit()
return self.get_by_uuid(existing.uuid)
ent_uuid = str(uuid_mod.uuid4())
now = time.time()
self.conn.execute(
"""
INSERT INTO entities (uuid, name, aliases, entity_type, description, first_seen, last_seen, occurrence_count, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
ent_uuid,
name,
json.dumps(aliases or []),
entity_type,
description,
now,
now,
1,
json.dumps(metadata or {}),
),
)
self.conn.commit()
return self.get_by_uuid(ent_uuid)
def _find_by_name(self, name: str) -> Optional[Entity]:
row = self.conn.execute(
"SELECT * FROM entities WHERE name = ? COLLATE NOCASE LIMIT 1", (name,)
).fetchone()
return self._row_to_entity(row) if row else None
def get_by_uuid(self, ent_uuid: str) -> Optional[Entity]:
row = self.conn.execute("SELECT * FROM entities WHERE uuid = ? LIMIT 1", (ent_uuid,)).fetchone()
return self._row_to_entity(row) if row else None
def query(
self,
name: Optional[str] = None,
entity_type: Optional[str] = None,
limit: int = 10,
) -> List[Entity]:
sql = "SELECT * FROM entities WHERE 1=1"
params: List = []
if name:
sql += " AND (name LIKE ? OR aliases LIKE ?)"
params.extend([f"%{name}%", f"%{name}%"])
if entity_type:
sql += " AND entity_type = ?"
params.append(entity_type)
sql += " ORDER BY occurrence_count DESC, last_seen DESC LIMIT ?"
params.append(limit)
rows = self.conn.execute(sql, params).fetchall()
return [self._row_to_entity(r) for r in rows]
def _row_to_entity(self, row: sqlite3.Row) -> Entity:
return Entity(
uuid=row["uuid"],
name=row["name"],
aliases=json.loads(row["aliases"] or "[]"),
entity_type=row["entity_type"],
description=row["description"],
first_seen=row["first_seen"],
last_seen=row["last_seen"],
occurrence_count=row["occurrence_count"],
metadata=json.loads(row["metadata"] or "{}"),
)
+166
View File
@@ -0,0 +1,166 @@
"""FactStore — CRUD für strukturierte Fakten in Tier 2."""
import hashlib
import sqlite3
import time
import uuid as uuid_mod
from dataclasses import dataclass
from typing import List, Optional
@dataclass
class Fact:
uuid: str
content: str
content_hash: str
category: Optional[str]
confidence: float
source_type: str
source_id: Optional[str]
created_at: float
updated_at: float
expires_at: Optional[float]
access_count: int
last_accessed: Optional[float]
is_archived: bool
class FactStore:
def __init__(self, conn: sqlite3.Connection):
self.conn = conn
@staticmethod
def _hash(content: str) -> str:
return hashlib.sha256(content.encode("utf-8")).hexdigest()
def store(
self,
content: str,
category: str = "general",
confidence: float = 1.0,
source_type: str = "user",
source_id: Optional[str] = None,
expires_at: Optional[float] = None,
) -> Fact:
content_hash = self._hash(content)
existing = self.get_by_hash(content_hash)
if existing and not existing.is_archived:
# Deduplizierung: Aktualisiere confidence und updated_at
self.conn.execute(
"UPDATE facts SET confidence = MAX(confidence, ?), updated_at = ?, access_count = access_count + 1 WHERE uuid = ?",
(confidence, time.time(), existing.uuid),
)
self.conn.commit()
return self.get_by_uuid(existing.uuid)
fact_uuid = str(uuid_mod.uuid4())
now = time.time()
self.conn.execute(
"""
INSERT INTO facts (uuid, content, content_hash, category, confidence, source_type, source_id, created_at, updated_at, expires_at, access_count, last_accessed, is_archived)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(fact_uuid, content, content_hash, category, confidence, source_type, source_id, now, now, expires_at, 0, None, 0),
)
self.conn.commit()
return self.get_by_uuid(fact_uuid)
def get_by_hash(self, content_hash: str) -> Optional[Fact]:
row = self.conn.execute("SELECT * FROM facts WHERE content_hash = ? LIMIT 1", (content_hash,)).fetchone()
return self._row_to_fact(row) if row else None
def get_by_uuid(self, fact_uuid: str) -> Optional[Fact]:
row = self.conn.execute("SELECT * FROM facts WHERE uuid = ? LIMIT 1", (fact_uuid,)).fetchone()
return self._row_to_fact(row) if row else None
def query(
self,
query: str = "",
category: Optional[str] = None,
limit: int = 10,
min_confidence: float = 0.5,
fts: bool = True,
) -> List[Fact]:
if fts and query:
# FTS5 + Filter
sql = """
SELECT f.* FROM facts f
JOIN facts_fts fts ON f.id = fts.rowid
WHERE fts.facts_fts MATCH ? AND f.confidence >= ? AND f.is_archived = 0
"""
params = [query, min_confidence]
if category:
sql += " AND f.category = ?"
params.append(category)
sql += " ORDER BY f.confidence DESC, f.created_at DESC LIMIT ?"
params.append(limit)
else:
sql = "SELECT * FROM facts WHERE confidence >= ? AND is_archived = 0"
params = [min_confidence]
if category:
sql += " AND category = ?"
params.append(category)
if query:
sql += " AND content LIKE ?"
params.append(f"%{query}%")
sql += " ORDER BY confidence DESC, created_at DESC LIMIT ?"
params.append(limit)
rows = self.conn.execute(sql, params).fetchall()
return [self._row_to_fact(r) for r in rows]
def update(self, fact_uuid: str, **fields) -> Optional[Fact]:
allowed = {"content", "category", "confidence", "expires_at", "is_archived"}
updates = {k: v for k, v in fields.items() if k in allowed}
if not updates:
return self.get_by_uuid(fact_uuid)
if "content" in updates:
updates["content_hash"] = self._hash(updates["content"])
updates["updated_at"] = time.time()
cols = ", ".join(f"{k} = ?" for k in updates)
vals = list(updates.values()) + [fact_uuid]
self.conn.execute(f"UPDATE facts SET {cols} WHERE uuid = ?", vals)
self.conn.commit()
return self.get_by_uuid(fact_uuid)
def delete(self, fact_uuid: str, soft: bool = True) -> bool:
if soft:
self.conn.execute("UPDATE facts SET is_archived = 1, updated_at = ? WHERE uuid = ?", (time.time(), fact_uuid))
else:
self.conn.execute("DELETE FROM facts WHERE uuid = ?", (fact_uuid,))
self.conn.commit()
return True
def deduplicate(self) -> int:
"""Merge Fakten mit identischem content_hash. Returns merged count."""
rows = self.conn.execute(
"SELECT content_hash, COUNT(*) as c FROM facts WHERE is_archived = 0 GROUP BY content_hash HAVING c > 1"
).fetchall()
merged = 0
for row in rows:
hashes = self.conn.execute(
"SELECT uuid FROM facts WHERE content_hash = ? AND is_archived = 0 ORDER BY created_at",
(row["content_hash"],),
).fetchall()
keep = hashes[0]["uuid"]
for dup in hashes[1:]:
self.delete(dup["uuid"], soft=False)
merged += 1
return merged
def _row_to_fact(self, row: sqlite3.Row) -> Fact:
return Fact(
uuid=row["uuid"],
content=row["content"],
content_hash=row["content_hash"],
category=row["category"],
confidence=row["confidence"],
source_type=row["source_type"],
source_id=row["source_id"],
created_at=row["created_at"],
updated_at=row["updated_at"],
expires_at=row["expires_at"],
access_count=row["access_count"],
last_accessed=row["last_accessed"],
is_archived=bool(row["is_archived"]),
)
+96
View File
@@ -0,0 +1,96 @@
"""RelationStore — Relationen-Management für Tier 2."""
import sqlite3
import time
import uuid as uuid_mod
from dataclasses import dataclass
from typing import List, Optional
@dataclass
class Relation:
uuid: str
from_entity_id: str
to_entity_id: str
relation_type: str
strength: float
evidence_fact_id: Optional[str]
created_at: float
updated_at: float
class RelationStore:
def __init__(self, conn: sqlite3.Connection):
self.conn = conn
def link(
self,
from_entity_id: str,
to_entity_id: str,
relation_type: str,
strength: float = 1.0,
evidence_fact_id: Optional[str] = None,
) -> Relation:
existing = self.conn.execute(
"SELECT uuid FROM relations WHERE from_entity_id = ? AND to_entity_id = ? AND relation_type = ?",
(from_entity_id, to_entity_id, relation_type),
).fetchone()
now = time.time()
if existing:
self.conn.execute(
"UPDATE relations SET strength = MAX(strength, ?), updated_at = ? WHERE uuid = ?",
(strength, now, existing["uuid"]),
)
self.conn.commit()
return self.get_by_uuid(existing["uuid"])
rel_uuid = str(uuid_mod.uuid4())
self.conn.execute(
"""
INSERT INTO relations (uuid, from_entity_id, to_entity_id, relation_type, strength, evidence_fact_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(rel_uuid, from_entity_id, to_entity_id, relation_type, strength, evidence_fact_id, now, now),
)
self.conn.commit()
return self.get_by_uuid(rel_uuid)
def get_by_uuid(self, rel_uuid: str) -> Optional[Relation]:
row = self.conn.execute("SELECT * FROM relations WHERE uuid = ? LIMIT 1", (rel_uuid,)).fetchone()
return self._row_to_relation(row) if row else None
def query(
self,
from_entity_id: Optional[str] = None,
to_entity_id: Optional[str] = None,
relation_type: Optional[str] = None,
limit: int = 50,
) -> List[Relation]:
sql = "SELECT * FROM relations WHERE 1=1"
params: List = []
if from_entity_id:
sql += " AND from_entity_id = ?"
params.append(from_entity_id)
if to_entity_id:
sql += " AND to_entity_id = ?"
params.append(to_entity_id)
if relation_type:
sql += " AND relation_type = ?"
params.append(relation_type)
sql += " ORDER BY strength DESC, updated_at DESC LIMIT ?"
params.append(limit)
rows = self.conn.execute(sql, params).fetchall()
return [self._row_to_relation(r) for r in rows]
def _row_to_relation(self, row: sqlite3.Row) -> Relation:
return Relation(
uuid=row["uuid"],
from_entity_id=row["from_entity_id"],
to_entity_id=row["to_entity_id"],
relation_type=row["relation_type"],
strength=row["strength"],
evidence_fact_id=row["evidence_fact_id"],
created_at=row["created_at"],
updated_at=row["updated_at"],
)
+196
View File
@@ -0,0 +1,196 @@
"""SQLite Schema & Migrationen für Tier 2.
Enthält alle CREATE TABLE / INDEX / TRIGGER Statements
mit Schema-Versionierung.
"""
import sqlite3
import time
from pathlib import Path
SCHEMA_VERSION = 1
SCHEMA_SQL = """
-- Schema-Version
CREATE TABLE IF NOT EXISTS memory_schema_version (
version INTEGER PRIMARY KEY,
applied_at REAL NOT NULL
);
-- Fakten
CREATE TABLE IF NOT EXISTS facts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
content TEXT NOT NULL,
content_hash TEXT NOT NULL,
category TEXT,
confidence REAL DEFAULT 1.0,
source_type TEXT NOT NULL,
source_id TEXT,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
expires_at REAL,
access_count INTEGER DEFAULT 0,
last_accessed REAL,
is_archived INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_facts_category ON facts(category);
CREATE INDEX IF NOT EXISTS idx_facts_source ON facts(source_type, source_id);
CREATE INDEX IF NOT EXISTS idx_facts_created ON facts(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_facts_hash ON facts(content_hash);
CREATE INDEX IF NOT EXISTS idx_facts_confidence ON facts(confidence DESC);
-- Entitäten
CREATE TABLE IF NOT EXISTS entities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
aliases TEXT,
entity_type TEXT NOT NULL,
description TEXT,
first_seen REAL NOT NULL,
last_seen REAL NOT NULL,
occurrence_count INTEGER DEFAULT 1,
metadata TEXT
);
CREATE INDEX IF NOT EXISTS idx_entities_name ON entities(name);
CREATE INDEX IF NOT EXISTS idx_entities_type ON entities(entity_type);
-- Relationen
CREATE TABLE IF NOT EXISTS relations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
from_entity_id TEXT NOT NULL,
to_entity_id TEXT NOT NULL,
relation_type TEXT NOT NULL,
strength REAL DEFAULT 1.0,
evidence_fact_id TEXT,
created_at REAL NOT NULL,
updated_at REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_relations_from ON relations(from_entity_id);
CREATE INDEX IF NOT EXISTS idx_relations_to ON relations(to_entity_id);
CREATE INDEX IF NOT EXISTS idx_relations_type ON relations(relation_type);
-- Timeline
CREATE TABLE IF NOT EXISTS timeline (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
event_type TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
related_entities TEXT,
related_facts TEXT,
session_id TEXT,
timestamp REAL NOT NULL,
importance REAL DEFAULT 0.5
);
CREATE INDEX IF NOT EXISTS idx_timeline_time ON timeline(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_timeline_type ON timeline(event_type);
-- Audit-Log
CREATE TABLE IF NOT EXISTS memory_audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp REAL NOT NULL,
action TEXT NOT NULL,
tier TEXT NOT NULL,
actor TEXT NOT NULL,
target_uuid TEXT,
diff TEXT,
success INTEGER DEFAULT 1
);
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON memory_audit_log(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_audit_actor ON memory_audit_log(actor);
-- Embedding-Queue
CREATE TABLE IF NOT EXISTS embedding_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fact_id TEXT,
content TEXT NOT NULL,
source_type TEXT NOT NULL,
session_id TEXT,
message_id INTEGER,
queued_at REAL NOT NULL,
processed INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_queue_processed ON embedding_queue(processed, queued_at);
"""
FTS_SQL = """
CREATE VIRTUAL TABLE IF NOT EXISTS facts_fts USING fts5(
content,
content_rowid='id',
tokenize='unicode61'
);
CREATE TRIGGER IF NOT EXISTS facts_fts_insert AFTER INSERT ON facts BEGIN
INSERT INTO facts_fts(rowid, content) VALUES (new.id, new.content);
END;
CREATE TRIGGER IF NOT EXISTS facts_fts_delete AFTER DELETE ON facts BEGIN
DELETE FROM facts_fts WHERE rowid = old.id;
END;
CREATE TRIGGER IF NOT EXISTS facts_fts_update AFTER UPDATE ON facts BEGIN
DELETE FROM facts_fts WHERE rowid = old.id;
INSERT INTO facts_fts(rowid, content) VALUES (new.id, new.content);
END;
CREATE VIRTUAL TABLE IF NOT EXISTS entities_fts USING fts5(
name,
content_rowid='id',
tokenize='unicode61'
);
CREATE TRIGGER IF NOT EXISTS entities_fts_insert AFTER INSERT ON entities BEGIN
INSERT INTO entities_fts(rowid, name) VALUES (new.id, new.name);
END;
CREATE TRIGGER IF NOT EXISTS entities_fts_delete AFTER DELETE ON entities BEGIN
DELETE FROM entities_fts WHERE rowid = old.id;
END;
CREATE TRIGGER IF NOT EXISTS entities_fts_update AFTER UPDATE ON entities BEGIN
DELETE FROM entities_fts WHERE rowid = old.id;
INSERT INTO entities_fts(rowid, name) VALUES (new.id, new.name);
END;
"""
def init_schema(conn: sqlite3.Connection) -> None:
"""Initialisiert alle Tabellen, Indizes und FTS5."""
conn.executescript(SCHEMA_SQL)
conn.executescript(FTS_SQL)
conn.execute(
"INSERT OR REPLACE INTO memory_schema_version (version, applied_at) VALUES (?, ?)",
(SCHEMA_VERSION, time.time()),
)
conn.commit()
def migrate(conn: sqlite3.Connection) -> None:
"""Führt Migrationen durch (aktuell: nur Schema-Version prüfen)."""
row = conn.execute("SELECT version FROM memory_schema_version ORDER BY version DESC LIMIT 1").fetchone()
current = row[0] if row else 0
if current < SCHEMA_VERSION:
init_schema(conn)
def connect(db_path: Path, wal_mode: bool = True) -> sqlite3.Connection:
"""Erstellt Verbindung mit WAL-Mode und Foreign Keys."""
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path), check_same_thread=False, timeout=10.0)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys=ON")
if wal_mode:
try:
conn.execute("PRAGMA journal_mode=WAL")
except sqlite3.OperationalError:
conn.execute("PRAGMA journal_mode=DELETE")
return conn
+98
View File
@@ -0,0 +1,98 @@
"""TimelineStore — Zeitachsen-Management für Tier 2."""
import json
import sqlite3
import time
import uuid as uuid_mod
from dataclasses import dataclass
from typing import List, Optional
@dataclass
class TimelineEvent:
uuid: str
event_type: str
title: str
description: Optional[str]
related_entities: List[str]
related_facts: List[str]
session_id: Optional[str]
timestamp: float
importance: float
class TimelineStore:
def __init__(self, conn: sqlite3.Connection):
self.conn = conn
def add(
self,
event_type: str,
title: str,
description: Optional[str] = None,
importance: float = 0.5,
related_entities: Optional[List[str]] = None,
related_facts: Optional[List[str]] = None,
session_id: Optional[str] = None,
) -> TimelineEvent:
ev_uuid = str(uuid_mod.uuid4())
now = time.time()
self.conn.execute(
"""
INSERT INTO timeline (uuid, event_type, title, description, related_entities, related_facts, session_id, timestamp, importance)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
ev_uuid,
event_type,
title,
description,
json.dumps(related_entities or []),
json.dumps(related_facts or []),
session_id,
now,
importance,
),
)
self.conn.commit()
return self.get_by_uuid(ev_uuid)
def get_by_uuid(self, ev_uuid: str) -> Optional[TimelineEvent]:
row = self.conn.execute("SELECT * FROM timeline WHERE uuid = ? LIMIT 1", (ev_uuid,)).fetchone()
return self._row_to_event(row) if row else None
def query(
self,
start: Optional[float] = None,
end: Optional[float] = None,
event_type: Optional[str] = None,
limit: int = 20,
) -> List[TimelineEvent]:
sql = "SELECT * FROM timeline WHERE 1=1"
params: List = []
if start:
sql += " AND timestamp >= ?"
params.append(start)
if end:
sql += " AND timestamp <= ?"
params.append(end)
if event_type:
sql += " AND event_type = ?"
params.append(event_type)
sql += " ORDER BY timestamp DESC LIMIT ?"
params.append(limit)
rows = self.conn.execute(sql, params).fetchall()
return [self._row_to_event(r) for r in rows]
def _row_to_event(self, row: sqlite3.Row) -> TimelineEvent:
return TimelineEvent(
uuid=row["uuid"],
event_type=row["event_type"],
title=row["title"],
description=row["description"],
related_entities=json.loads(row["related_entities"] or "[]"),
related_facts=json.loads(row["related_facts"] or "[]"),
session_id=row["session_id"],
timestamp=row["timestamp"],
importance=row["importance"],
)