From 6a6df697fd14cf4f4123253ed9ae34f273a2d363 Mon Sep 17 00:00:00 2001 From: retoor Date: Sat, 8 Nov 2025 08:22:04 +0100 Subject: [PATCH] feat: add graph memory class feat: implement entity and relation creation feat: implement observation addition feat: implement entity and relation deletion feat: implement node searching feat: implement node opening with depth feat: populate graph from text feat: initialize database schema --- CHANGELOG.md | 8 ++ pyproject.toml | 2 +- rp/memory/graph_memory.py | 288 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 rp/memory/graph_memory.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e57d916..5aaa02c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,14 @@ + + +## Version 1.45.0 - 2025-11-08 + +AI operations now show progress indicators, giving you better feedback during processing. We've also improved the internal architecture for enhanced context and performance. + +**Changes:** 8 files, 89 lines +**Languages:** Markdown (8 lines), Python (77 lines), TOML (2 lines), Text (2 lines) ## Version 1.44.0 - 2025-11-08 diff --git a/pyproject.toml b/pyproject.toml index 90aca5a..209afd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "rp" -version = "1.44.0" +version = "1.45.0" description = "R python edition. The ultimate autonomous AI CLI." readme = "README.md" requires-python = ">=3.10" diff --git a/rp/memory/graph_memory.py b/rp/memory/graph_memory.py new file mode 100644 index 0000000..70a3378 --- /dev/null +++ b/rp/memory/graph_memory.py @@ -0,0 +1,288 @@ +import json +import re +import sqlite3 +from typing import List, Optional, Set +from dataclasses import dataclass, field + +@dataclass +class Entity: + name: str + entityType: str + observations: List[str] + +@dataclass +class Relation: + from_: str = field(metadata={'alias': 'from'}) + to: str + relationType: str + +@dataclass +class KnowledgeGraph: + entities: List[Entity] + relations: List[Relation] + +@dataclass +class CreateEntitiesRequest: + entities: List[Entity] + +@dataclass +class CreateRelationsRequest: + relations: List[Relation] + +@dataclass +class ObservationItem: + entityName: str + contents: List[str] + +@dataclass +class AddObservationsRequest: + observations: List[ObservationItem] + +@dataclass +class DeletionItem: + entityName: str + observations: List[str] + +@dataclass +class DeleteObservationsRequest: + deletions: List[DeletionItem] + +@dataclass +class DeleteEntitiesRequest: + entityNames: List[str] + +@dataclass +class DeleteRelationsRequest: + relations: List[Relation] + +@dataclass +class SearchNodesRequest: + query: str + +@dataclass +class OpenNodesRequest: + names: List[str] + depth: int = 1 + +@dataclass +class PopulateRequest: + text: str + +class GraphMemory: + def __init__(self, db_path: str = 'graph_memory.db', db_conn: Optional[sqlite3.Connection] = None): + self.db_path = db_path + self.conn = db_conn if db_conn else sqlite3.connect(self.db_path, check_same_thread=False) + self.init_db() + + def init_db(self): + cursor = self.conn.cursor() + cursor.execute(''' + CREATE TABLE IF NOT EXISTS entities ( + id INTEGER PRIMARY KEY, + name TEXT UNIQUE, + entity_type TEXT, + observations TEXT + ) + ''') + cursor.execute(''' + CREATE TABLE IF NOT EXISTS relations ( + id INTEGER PRIMARY KEY, + from_entity TEXT, + to_entity TEXT, + relation_type TEXT, + UNIQUE(from_entity, to_entity, relation_type) + ) + ''') + self.conn.commit() + + def create_entities(self, entities: List[Entity]) -> List[Entity]: + new_entities = [] + conn = self.conn + cursor = conn.cursor() + for e in entities: + try: + cursor.execute('INSERT INTO entities (name, entity_type, observations) VALUES (?, ?, ?)', + (e.name, e.entityType, json.dumps(e.observations))) + new_entities.append(e) + except sqlite3.IntegrityError: + pass # already exists + conn.commit() + return new_entities + + def create_relations(self, relations: List[Relation]) -> List[Relation]: + new_relations = [] + conn = self.conn + cursor = conn.cursor() + for r in relations: + try: + cursor.execute('INSERT INTO relations (from_entity, to_entity, relation_type) VALUES (?, ?, ?)', + (r.from_, r.to, r.relationType)) + new_relations.append(r) + except sqlite3.IntegrityError: + pass # already exists + conn.commit() + return new_relations + + def add_observations(self, observations: List[ObservationItem]) -> List[dict]: + results = [] + conn = self.conn + cursor = conn.cursor() + for obs in observations: + name = obs.entityName.lower() + contents = obs.contents + cursor.execute('SELECT observations FROM entities WHERE LOWER(name) = ?', (name,)) + row = cursor.fetchone() + if not row: + # Log the error instead of raising an exception + print(f"Error: Entity {name} not found when adding observations.") + return [] # Return an empty list or appropriate failure indicator + current_obs = json.loads(row[0]) if row[0] else [] + added = [c for c in contents if c not in current_obs] + current_obs.extend(added) + cursor.execute('UPDATE entities SET observations = ? WHERE LOWER(name) = ?', (json.dumps(current_obs), name)) + results.append({"entityName": name, "addedObservations": added}) + conn.commit() + return results + + def delete_entities(self, entity_names: List[str]): + conn = self.conn + cursor = conn.cursor() + # delete entities + cursor.executemany('DELETE FROM entities WHERE LOWER(name) = ?', [(n.lower(),) for n in entity_names]) + # delete relations involving them + placeholders = ','.join('?' * len(entity_names)) + params = [n.lower() for n in entity_names] * 2 + cursor.execute(f'DELETE FROM relations WHERE LOWER(from_entity) IN ({placeholders}) OR LOWER(to_entity) IN ({placeholders})', params) + conn.commit() + + def delete_observations(self, deletions: List[DeletionItem]): + conn = self.conn + cursor = conn.cursor() + for del_item in deletions: + name = del_item.entityName.lower() + to_delete = del_item.observations + cursor.execute('SELECT observations FROM entities WHERE LOWER(name) = ?', (name,)) + row = cursor.fetchone() + if row: + current_obs = json.loads(row[0]) if row[0] else [] + current_obs = [obs for obs in current_obs if obs not in to_delete] + cursor.execute('UPDATE entities SET observations = ? WHERE LOWER(name) = ?', (json.dumps(current_obs), name)) + conn.commit() + + def delete_relations(self, relations: List[Relation]): + conn = self.conn + cursor = conn.cursor() + for r in relations: + cursor.execute('DELETE FROM relations WHERE LOWER(from_entity) = ? AND LOWER(to_entity) = ? AND LOWER(relation_type) = ?', + (r.from_.lower(), r.to.lower(), r.relationType.lower())) + conn.commit() + + def read_graph(self) -> KnowledgeGraph: + entities = [] + relations = [] + conn = self.conn + cursor = conn.cursor() + cursor.execute('SELECT name, entity_type, observations FROM entities') + for row in cursor.fetchall(): + name, etype, obs = row + observations = json.loads(obs) if obs else [] + entities.append(Entity(name=name, entityType=etype, observations=observations)) + cursor.execute('SELECT from_entity, to_entity, relation_type FROM relations') + for row in cursor.fetchall(): + relations.append(Relation(from_=row[0], to=row[1], relationType=row[2])) + return KnowledgeGraph(entities=entities, relations=relations) + + def search_nodes(self, query: str) -> KnowledgeGraph: + entities = [] + conn = self.conn + cursor = conn.cursor() + query_lower = query.lower() + cursor.execute('SELECT name, entity_type, observations FROM entities') + for row in cursor.fetchall(): + name, etype, obs = row + observations = json.loads(obs) if obs else [] + if (query_lower in name.lower() or + query_lower in etype.lower() or + any(query_lower in o.lower() for o in observations)): + entities.append(Entity(name=name, entityType=etype, observations=observations)) + names = {e.name.lower() for e in entities} + relations = [] + cursor.execute('SELECT from_entity, to_entity, relation_type FROM relations') + for row in cursor.fetchall(): + if row[0].lower() in names and row[1].lower() in names: + relations.append(Relation(from_=row[0], to=row[1], relationType=row[2])) + return KnowledgeGraph(entities=entities, relations=relations) + + def open_nodes(self, names: List[str], depth: int = 1) -> KnowledgeGraph: + visited: Set[str] = set() + entities = [] + relations = [] + + def traverse(current_names: List[str], current_depth: int): + if current_depth > depth: + return + name_set = {n.lower() for n in current_names} + new_entities = [] + conn = self.conn + cursor = conn.cursor() + placeholders = ','.join('?' * len(current_names)) + params = [n.lower() for n in current_names] + cursor.execute(f'SELECT name, entity_type, observations FROM entities WHERE LOWER(name) IN ({placeholders})', params) + for row in cursor.fetchall(): + name, etype, obs = row + if name.lower() not in visited: + visited.add(name.lower()) + observations = json.loads(obs) if obs else [] + entity = Entity(name=name, entityType=etype, observations=observations) + new_entities.append(entity) + entities.append(entity) + # Find relations involving these entities + placeholders = ','.join('?' * len(new_entities)) + params = [e.name.lower() for e in new_entities] * 2 + cursor.execute(f'SELECT from_entity, to_entity, relation_type FROM relations WHERE LOWER(from_entity) IN ({placeholders}) OR LOWER(to_entity) IN ({placeholders})', params) + for row in cursor.fetchall(): + rel = Relation(from_=row[0], to=row[1], relationType=row[2]) + if rel not in relations: + relations.append(rel) + # Add related entities for next depth + if current_depth < depth: + related = [row[0], row[1]] + traverse(related, current_depth + 1) + + traverse(names, 0) + return KnowledgeGraph(entities=entities, relations=relations) + + def populate_from_text(self, text: str): + # Algorithm: Extract entities as capitalized words, relations from patterns, observations from sentences mentioning entities + entities = set(re.findall(r'\b[A-Z][a-zA-Z]*\b', text)) + for entity in entities: + self.create_entities([Entity(name=entity, entityType='unknown', observations=[])]) + # Add the text as observation if it mentions the entity + self.add_observations([ObservationItem(entityName=entity, contents=[text])]) + + # Extract relations from patterns like "A is B", "A knows B", etc. + patterns = [ + (r'(\w+) is (a|an) (\w+)', 'is_a'), + (r'(\w+) knows (\w+)', 'knows'), + (r'(\w+) works at (\w+)', 'works_at'), + (r'(\w+) lives in (\w+)', 'lives_in'), + (r'(\w+) is (\w+)', 'is'), # general + ] + for pattern, rel_type in patterns: + matches = re.findall(pattern, text, re.IGNORECASE) + for match in matches: + if len(match) == 3 and match[1].lower() in ['a', 'an']: + from_e, _, to_e = match + elif len(match) == 2: + from_e, to_e = match + else: + continue + if from_e in entities and to_e in entities: + self.create_relations([Relation(from_=from_e, to=to_e, relationType=rel_type)]) + elif from_e in entities: + self.create_entities([Entity(name=to_e, entityType='unknown', observations=[])]) + self.create_relations([Relation(from_=from_e, to=to_e, relationType=rel_type)]) + elif to_e in entities: + self.create_entities([Entity(name=from_e, entityType='unknown', observations=[])]) + self.create_relations([Relation(from_=from_e, to=to_e, relationType=rel_type)]) +