epistemic-graph-memory 1.2.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.
@@ -0,0 +1,154 @@
1
+ Metadata-Version: 2.4
2
+ Name: epistemic-graph-memory
3
+ Version: 1.2.0
4
+ Summary: A universal, long-term project memory tool utilizing a local SQLite graph structure for AI agents.
5
+ Author: Divyansh Ailani
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: License :: OSI Approved :: MIT License
8
+ Classifier: Operating System :: OS Independent
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: mcp>=1.1.2
12
+
13
+ # Graph-Memory
14
+
15
+ A universal, long-term project memory tool utilizing a local SQLite graph structure to solve context amnesia for AI coding agents.
16
+
17
+ **Natively built for Antigravity (AG)**, Graph-Memory provides autonomous AI agents with a highly structured, relational brain. It is exposed universally via the **Model Context Protocol (MCP)**, meaning any framework (Claude Desktop, Cursor, Codex, Aider) can share and update the exact same graph in real-time.
18
+
19
+ ## Overview
20
+ When AI agents work on complex software projects over long periods, flat markdown files like `project_memory.md` often fail because they lack structural context and the agent forgets the relationships between different files, tasks, and infrastructure.
21
+
22
+ **Graph-Memory** solves this by providing a **Trust-Weighted Epistemic Graph** database (SQLite-backed) that your agents interact with to store long-term architecture. It forces agents to provide confidence levels on the facts they log, entirely preventing silent hallucinations.
23
+
24
+ ## File Structure
25
+
26
+ ```
27
+ Graph memory/
28
+ ├── .agents/ # Database and exports (auto-generated per workspace)
29
+ │ ├── graph_memory.sqlite # The isolated SQLite brain
30
+ │ └── graph_memory_vis.html # The interactive visual graph
31
+ ├── scripts/
32
+ │ ├── db.py # Core SQLite bindings, schema, and epistemic logic
33
+ │ ├── mcp_server.py # The Universal Model Context Protocol (MCP) Server
34
+ │ └── memory_tool.py # The CLI tool & HTML Visualizer
35
+ ├── SKILL.md # Antigravity native skill instructions and rules
36
+ ├── README.md # This documentation
37
+ ├── CHANGELOG.md # Version history
38
+ └── ISSUES.md # Known bugs and future roadmap
39
+ ```
40
+
41
+ ## Features
42
+ - **Trust-Weighted Epistemic Graph**: The graph strictly tracks *when* a fact was logged, and *how* it was verified. If an agent just assumes a fact, the graph flags it as stale. It only trusts explicit code reads and executed tests.
43
+ - **Idempotent Nodes & Edges**: Agents log Tasks, Decisions, Infrastructure, and Bugs as connected nodes.
44
+ - **Strict Modeling Rules**: Rules enforced via instructions ensure no orphaned nodes and strict typing.
45
+ - **Obsidian-style Vis.js HTML Export**: Generate beautiful, physics-based, dark-mode graph visualizations in your browser. Stale or hallucinated nodes are visually flagged.
46
+ - **Universal State**: The database is stored locally in `.agents/graph_memory.sqlite`, meaning Claude Desktop, Cursor, and Antigravity can all read/write to the exact same brain simultaneously.
47
+
48
+ ## Quickstart
49
+
50
+ ### 1. Installation
51
+
52
+ You can now install Graph-Memory globally via pip! No more cloning needed.
53
+
54
+ ```bash
55
+ pip install epistemic-graph-memory
56
+ ```
57
+
58
+ This installs two global commands:
59
+ - `graph-memory` (The local CLI tool)
60
+ - `graph-memory-mcp` (The MCP server for AI agents)
61
+
62
+ ### 2. Connect to your AI
63
+
64
+ Graph-Memory exposes the exact 9 standard MCP Tool signatures (`create_entities`, `search_nodes`, `add_observations`, etc.). This makes it a **100% Drop-In Replacement** for the official Anthropic Memory Server.
65
+
66
+ Add the following to your AI framework's configuration:
67
+
68
+ **macOS/Linux:**
69
+ ```json
70
+ {
71
+ "mcpServers": {
72
+ "graph-memory": {
73
+ "command": "graph-memory-mcp"
74
+ }
75
+ }
76
+ }
77
+ ```
78
+
79
+ **Windows:**
80
+ ```json
81
+ {
82
+ "mcpServers": {
83
+ "graph-memory": {
84
+ "command": "graph-memory-mcp"
85
+ }
86
+ }
87
+ }
88
+ ```
89
+
90
+ ---
91
+
92
+ ## Pro-Tip: Enabling "Live" Auto-Updates
93
+ In Antigravity (AG), Graph-Memory is deeply integrated, meaning the agent automatically updates the database in the background without you asking.
94
+
95
+ **To get this same "Live Auto-Update" behavior in Claude Desktop, Cursor, or Codex**, you must paste the following rule into your **Project Instructions**, `.cursorrules`, or `.codexrules` file:
96
+
97
+ ```markdown
98
+ # Automated Graph Memory Tracking
99
+ You have access to a `graph_memory` MCP server. You MUST proactively and automatically use the `add_node` and `add_relation` tools to track project state without the user explicitly asking you to.
100
+
101
+ Whenever you:
102
+ 1. Complete a significant task or milestone.
103
+ 2. Make an architectural decision.
104
+ 3. Discover or setup new infrastructure.
105
+
106
+ Quietly run the graph tools at the end of your turn to log this information so it isn't forgotten.
107
+ ```
108
+ *Without this rule, Claude/Cursor will treat the graph as a purely manual tool and will only update it when explicitly asked.*
109
+
110
+ ---
111
+
112
+ ## Antigravity Installation (Native)
113
+ If you are using Antigravity, you can link this repository directly into your skills folder to use it natively via CLI.
114
+
115
+ **macOS/Linux:**
116
+ ```bash
117
+ ln -s "/path/to/Graph memory" ~/.gemini/config/skills/graph_memory
118
+ ```
119
+
120
+ **Windows (Run Command Prompt as Administrator):**
121
+ ```cmd
122
+ mklink /D "C:\Users\YOUR_USERNAME\.gemini\config\skills\graph_memory" "C:\path\to\Graph memory"
123
+ ```
124
+
125
+ ### Manual CLI Tools
126
+
127
+ You can interact with the graph database directly from your terminal using the installed `graph-memory` command! By default, it will look for `.agents/graph_memory.sqlite` in your current working directory.
128
+
129
+ ```bash
130
+ # Add an Entity with JSON properties (Observations)
131
+ graph-memory add_node "Postgres_DB" "Database" '{"observations": ["Assumed based on backend code."]}'
132
+
133
+ # Add a Relation
134
+ graph-memory add_relation "Server_VM" "HAS_DB" "Postgres_DB"
135
+
136
+ # Get a Node's Subgraph
137
+ graph-memory get_node "Postgres_DB"
138
+
139
+ # Search using FTS5 Natural Language
140
+ graph-memory search "Assumed based on backend"
141
+
142
+ # Soft-Delete a Node
143
+ graph-memory delete_node "Postgres_DB"
144
+
145
+ # Export Graph to an Interactive HTML file
146
+ graph-memory export_html my_graph.html
147
+ ```
148
+
149
+ ## Advanced Configuration
150
+
151
+ You can override the default database location by setting the environment variable:
152
+ ```bash
153
+ export GRAPH_MEMORY_DB_PATH="/path/to/my_global_brain.sqlite"
154
+ ```
@@ -0,0 +1,12 @@
1
+ graph_memory/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ graph_memory/cli.py,sha256=0yFSCDhjFj_3gfVC2sqFL74sVVUvoum2lsHnrvW7LK0,6404
3
+ graph_memory/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ graph_memory/core/engine.py,sha256=--BGt9BLvIjHKmYmDRIHARPTftBPJ9zYJD0W5JpHnVI,13835
5
+ graph_memory/integrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ graph_memory/mcp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ graph_memory/mcp/server.py,sha256=pJ2uWwaObTeRARZUlMke1VqbH5tzIyW4nUdKjNO5Sj8,12781
8
+ epistemic_graph_memory-1.2.0.dist-info/METADATA,sha256=rgLVUZIFLwCv1kmLe8GfO61aKNuQ4lWML68hlKpEmx0,6283
9
+ epistemic_graph_memory-1.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
10
+ epistemic_graph_memory-1.2.0.dist-info/entry_points.txt,sha256=T1BmYtsC9vk2NbcgCNdGr8-SbOix4WCCjTnY-iFObAQ,103
11
+ epistemic_graph_memory-1.2.0.dist-info/top_level.txt,sha256=sl6KUYXhExtlOvLC5RGwfXzfWxtz5CrPtoBUWgR8-eg,13
12
+ epistemic_graph_memory-1.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ graph-memory = graph_memory.cli:main
3
+ graph-memory-mcp = graph_memory.mcp.server:main
@@ -0,0 +1 @@
1
+ graph_memory
File without changes
graph_memory/cli.py ADDED
@@ -0,0 +1,145 @@
1
+ import argparse
2
+ import sys
3
+ import json
4
+ import os
5
+ import glob
6
+
7
+ from graph_memory.core import engine
8
+
9
+ def main():
10
+ parser = argparse.ArgumentParser(description="Graph-Memory CLI Tool")
11
+ parser.add_argument("--db", type=str, help="Path to the SQLite database (defaults to workspace/.agents/graph_memory.sqlite)")
12
+ subparsers = parser.add_subparsers(dest="command", required=True)
13
+
14
+ # get_node
15
+ get_node_parser = subparsers.add_parser("get_node", help="Get a node and its relationships")
16
+ get_node_parser.add_argument("node_id", type=str, help="The ID of the node to retrieve")
17
+
18
+ # delete_node
19
+ delete_node_parser = subparsers.add_parser("delete_node", help="Soft-delete a node")
20
+ delete_node_parser.add_argument("node_id", type=str, help="The ID of the node to soft-delete")
21
+
22
+ # add_node (Maps to create_entities / add_observation)
23
+ add_node_parser = subparsers.add_parser("add_node", help="Add or update a node")
24
+ add_node_parser.add_argument("node_id", type=str, help="The unique identifier for the node")
25
+ add_node_parser.add_argument("label", type=str, help="The label/type for the node")
26
+ add_node_parser.add_argument("properties", type=str, nargs="?", default="{}", help="JSON properties (optional)")
27
+
28
+ # add_relation (Maps to create_relations)
29
+ add_relation_parser = subparsers.add_parser("add_relation", help="Add a relationship between nodes")
30
+ add_relation_parser.add_argument("source_id", type=str, help="Source node ID")
31
+ add_relation_parser.add_argument("relation_type", type=str, help="Type of relationship")
32
+ add_relation_parser.add_argument("target_id", type=str, help="Target node ID")
33
+ add_relation_parser.add_argument("properties", type=str, nargs="?", default="{}", help="JSON properties (optional)")
34
+
35
+ # search
36
+ search_parser = subparsers.add_parser("search", help="Full-text search across nodes")
37
+ search_parser.add_argument("query", type=str, help="Search query")
38
+
39
+ # import_md
40
+ import_md_parser = subparsers.add_parser("import", help="Import legacy markdown files into the graph")
41
+ import_md_parser.add_argument("directory", type=str, help="Directory containing .md files")
42
+
43
+ # export_html
44
+ export_html_parser = subparsers.add_parser("export_html", help="Export the graph to an HTML visualization")
45
+ export_html_parser.add_argument("output_file", type=str, help="Output HTML file path")
46
+
47
+ args = parser.parse_args()
48
+
49
+ db_path = args.db or engine.get_db_path()
50
+ engine.init_db(db_path)
51
+
52
+ try:
53
+ if args.command == "get_node":
54
+ result = engine.serialize_subgraph(db_path, args.node_id)
55
+ print(result)
56
+
57
+ elif args.command == "delete_node":
58
+ engine.soft_delete_entity(db_path, args.node_id)
59
+ print(f"Node '{args.node_id}' soft-deleted successfully.")
60
+
61
+ elif args.command == "add_node":
62
+ props = json.loads(args.properties)
63
+ engine.get_or_create_node(db_path, args.node_id, args.label, props)
64
+ print(f"Node '{args.node_id}' added/updated successfully.")
65
+
66
+ elif args.command == "add_relation":
67
+ props = json.loads(args.properties)
68
+ engine.create_relation(db_path, args.source_id, args.target_id, args.relation_type, props)
69
+ print(f"Relation created: {args.source_id} -[{args.relation_type}]-> {args.target_id}")
70
+
71
+ elif args.command == "search":
72
+ results = engine.search_nodes(db_path, args.query)
73
+ print(json.dumps(results, indent=2))
74
+
75
+ elif args.command == "import":
76
+ md_files = glob.glob(os.path.join(args.directory, "**/*.md"), recursive=True)
77
+ for fpath in md_files:
78
+ basename = os.path.basename(fpath)
79
+ node_id = os.path.splitext(basename)[0]
80
+ try:
81
+ with open(fpath, "r", encoding="utf-8") as f:
82
+ content = f.read()
83
+ # Create node
84
+ engine.get_or_create_node(db_path, node_id, "Document", {"type": "legacy_markdown"})
85
+ # Add content as observation
86
+ engine.add_observation(db_path, node_id, f"Content snippet: {content[:500]}")
87
+ print(f"Imported: {node_id}")
88
+ except Exception as e:
89
+ print(f"Error importing {fpath}: {e}")
90
+
91
+ elif args.command == "export_html":
92
+ graph = engine.read_graph(db_path)
93
+
94
+ # Simple HTML generator using vis.js
95
+ nodes_js = []
96
+ for n in graph["nodes"]:
97
+ nodes_js.append({
98
+ "id": n["id"],
99
+ "label": n["id"],
100
+ "title": json.dumps(n["properties"])
101
+ })
102
+
103
+ edges_js = []
104
+ for e in graph["edges"]:
105
+ edges_js.append({
106
+ "from": e["source_id"],
107
+ "to": e["target_id"],
108
+ "label": e["relation_type"],
109
+ "title": json.dumps(e["properties"])
110
+ })
111
+
112
+ html_content = f"""
113
+ <!DOCTYPE html>
114
+ <html>
115
+ <head>
116
+ <title>Graph Memory Visualization</title>
117
+ <script type="text/javascript" src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
118
+ <style type="text/css">
119
+ #mynetwork {{ width: 100vw; height: 100vh; border: 1px solid lightgray; }}
120
+ </style>
121
+ </head>
122
+ <body>
123
+ <div id="mynetwork"></div>
124
+ <script type="text/javascript">
125
+ var nodes = new vis.DataSet({json.dumps(nodes_js)});
126
+ var edges = new vis.DataSet({json.dumps(edges_js)});
127
+ var container = document.getElementById('mynetwork');
128
+ var data = {{ nodes: nodes, edges: edges }};
129
+ var options = {{}};
130
+ var network = new vis.Network(container, data, options);
131
+ </script>
132
+ </body>
133
+ </html>
134
+ """
135
+
136
+ with open(args.output_file, "w") as f:
137
+ f.write(html_content)
138
+ print(f"Exported HTML visualization to {args.output_file}")
139
+
140
+ except Exception as e:
141
+ print(f"Error: {e}")
142
+ sys.exit(1)
143
+
144
+ if __name__ == "__main__":
145
+ main()
File without changes
@@ -0,0 +1,355 @@
1
+ import sqlite3
2
+ import json
3
+ import os
4
+ from contextlib import contextmanager
5
+ from datetime import datetime, timezone
6
+
7
+ # ---------------------------------------------------------------------------
8
+ # Database Configuration
9
+ # ---------------------------------------------------------------------------
10
+
11
+ def get_db_path(workspace_dir: str = None) -> str:
12
+ """
13
+ Resolve the SQLite database path. Checks GRAPH_MEMORY_DB_PATH env var first,
14
+ then falls back to workspace_dir/.agents/graph_memory.sqlite.
15
+ """
16
+ env_path = os.environ.get("GRAPH_MEMORY_DB_PATH")
17
+ if env_path:
18
+ return env_path
19
+
20
+ if not workspace_dir:
21
+ workspace_dir = os.getcwd()
22
+
23
+ agents_dir = os.path.join(workspace_dir, ".agents")
24
+ os.makedirs(agents_dir, exist_ok=True)
25
+ return os.path.join(agents_dir, "graph_memory.sqlite")
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Concurrency & Transactions
29
+ # ---------------------------------------------------------------------------
30
+
31
+ @contextmanager
32
+ def get_connection(db_path: str):
33
+ """
34
+ Provides a base connection with required PRAGMAs for concurrency and integrity.
35
+ """
36
+ # check_same_thread=False allows multi-agent thread pools (like CrewAI) to share connections safely.
37
+ conn = sqlite3.connect(db_path, check_same_thread=False, timeout=30.0)
38
+ # WAL mode for concurrent readers and a single writer
39
+ conn.execute("PRAGMA journal_mode = WAL;")
40
+ # Enforce foreign key constraints
41
+ conn.execute("PRAGMA foreign_keys = ON;")
42
+ # Enable auto-vacuum to instantly reclaim space when nodes are pruned/soft-deleted
43
+ conn.execute("PRAGMA auto_vacuum = INCREMENTAL;")
44
+
45
+ try:
46
+ yield conn
47
+ finally:
48
+ conn.close()
49
+
50
+ @contextmanager
51
+ def write_transaction(conn: sqlite3.Connection):
52
+ """
53
+ Forces an immediate write-lock.
54
+ Prevents 'database is locked' deadlock errors when multiple agents try to write simultaneously in WAL mode.
55
+ """
56
+ conn.execute("BEGIN IMMEDIATE;")
57
+ try:
58
+ yield conn
59
+ conn.commit()
60
+ except Exception as e:
61
+ conn.rollback()
62
+ raise e
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Schema Initialization
66
+ # ---------------------------------------------------------------------------
67
+
68
+ def init_db(db_path: str):
69
+ """
70
+ Initialize the Trust-Weighted Epistemic Graph schema.
71
+ """
72
+ with get_connection(db_path) as conn:
73
+ with write_transaction(conn):
74
+ # Nodes Table (Entities)
75
+ # Includes tracking for memory decay and soft deletes.
76
+ conn.execute("""
77
+ CREATE TABLE IF NOT EXISTS Nodes (
78
+ id TEXT PRIMARY KEY,
79
+ label TEXT NOT NULL,
80
+ properties TEXT, -- JSON payload
81
+ created_at TEXT NOT NULL,
82
+ last_verified_at TEXT,
83
+ updated_at TEXT NOT NULL,
84
+ access_count INTEGER DEFAULT 0,
85
+ status TEXT DEFAULT 'active', -- active, superseded
86
+ is_deleted INTEGER DEFAULT 0
87
+ )
88
+ """)
89
+
90
+ # Edges Table (Relations)
91
+ # Uses ON DELETE CASCADE and a UNIQUE composite key.
92
+ conn.execute("""
93
+ CREATE TABLE IF NOT EXISTS Edges (
94
+ source_id TEXT NOT NULL,
95
+ target_id TEXT NOT NULL,
96
+ relation_type TEXT NOT NULL,
97
+ properties TEXT, -- JSON payload
98
+ created_at TEXT NOT NULL,
99
+ last_verified_at TEXT,
100
+ status TEXT DEFAULT 'active', -- active, superseded
101
+ FOREIGN KEY(source_id) REFERENCES Nodes(id) ON DELETE CASCADE,
102
+ FOREIGN KEY(target_id) REFERENCES Nodes(id) ON DELETE CASCADE,
103
+ UNIQUE(source_id, target_id, relation_type)
104
+ )
105
+ """)
106
+
107
+ # FTS5 Shadow Table for Full-Text Search
108
+ conn.execute("""
109
+ CREATE VIRTUAL TABLE IF NOT EXISTS NodesFTS USING fts5(
110
+ id, label, properties, content='Nodes', content_rowid='rowid'
111
+ )
112
+ """)
113
+
114
+ # Triggers to keep FTS5 synchronized with the main Nodes table
115
+ conn.execute("""
116
+ CREATE TRIGGER IF NOT EXISTS tr_nodes_ai AFTER INSERT ON Nodes BEGIN
117
+ INSERT INTO NodesFTS(rowid, id, label, properties)
118
+ VALUES (new.rowid, new.id, new.label, new.properties);
119
+ END;
120
+ """)
121
+ conn.execute("""
122
+ CREATE TRIGGER IF NOT EXISTS tr_nodes_ad AFTER DELETE ON Nodes BEGIN
123
+ INSERT INTO NodesFTS(NodesFTS, rowid, id, label, properties)
124
+ VALUES ('delete', old.rowid, old.id, old.label, old.properties);
125
+ END;
126
+ """)
127
+ conn.execute("""
128
+ CREATE TRIGGER IF NOT EXISTS tr_nodes_au AFTER UPDATE ON Nodes BEGIN
129
+ INSERT INTO NodesFTS(NodesFTS, rowid, id, label, properties)
130
+ VALUES ('delete', old.rowid, old.id, old.label, old.properties);
131
+ INSERT INTO NodesFTS(rowid, id, label, properties)
132
+ VALUES (new.rowid, new.id, new.label, new.properties);
133
+ END;
134
+ """)
135
+
136
+ # JSON Expression Index
137
+ conn.execute("""
138
+ CREATE INDEX IF NOT EXISTS idx_nodes_type
139
+ ON Nodes(json_extract(properties, '$.type'))
140
+ """)
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Core Operations
144
+ # ---------------------------------------------------------------------------
145
+
146
+ def now_iso() -> str:
147
+ return datetime.now(timezone.utc).isoformat()
148
+
149
+ def search_nodes(db_path: str, query: str) -> list:
150
+ """
151
+ Full-Text Search across the graph using FTS5. Excludes soft-deleted nodes.
152
+ """
153
+ init_db(db_path)
154
+ with get_connection(db_path) as conn:
155
+ cursor = conn.execute("""
156
+ SELECT n.id, n.label, n.properties, n.status
157
+ FROM NodesFTS f
158
+ JOIN Nodes n ON f.rowid = n.rowid
159
+ WHERE NodesFTS MATCH ? AND n.is_deleted = 0
160
+ ORDER BY rank
161
+ LIMIT 20
162
+ """, (query,))
163
+
164
+ results = []
165
+ for row in cursor.fetchall():
166
+ results.append({
167
+ "id": row[0],
168
+ "label": row[1],
169
+ "properties": json.loads(row[2]) if row[2] else {},
170
+ "status": row[3]
171
+ })
172
+
173
+ # Update access count to prevent memory decay
174
+ with write_transaction(conn):
175
+ conn.execute("""
176
+ UPDATE Nodes
177
+ SET access_count = access_count + 1, updated_at = ?
178
+ WHERE id = ?
179
+ """, (now_iso(), row[0]))
180
+
181
+ return results
182
+
183
+ def get_or_create_node(db_path: str, node_id: str, label: str, properties: dict = None) -> str:
184
+ """
185
+ Creates a node, or returns an existing one to prevent fragmentation.
186
+ Implements the "Supersession" problem solution.
187
+ """
188
+ init_db(db_path)
189
+ props = properties or {}
190
+
191
+ with get_connection(db_path) as conn:
192
+ with write_transaction(conn):
193
+ # Check for exact match first
194
+ row = conn.execute("SELECT id, properties FROM Nodes WHERE id = ? AND is_deleted = 0", (node_id,)).fetchone()
195
+
196
+ if row:
197
+ # Update properties (observations map into properties here)
198
+ existing_props = json.loads(row[1]) if row[1] else {}
199
+ existing_props.update(props)
200
+ conn.execute("""
201
+ UPDATE Nodes
202
+ SET properties = ?, updated_at = ?, access_count = access_count + 1
203
+ WHERE id = ?
204
+ """, (json.dumps(existing_props), now_iso(), node_id))
205
+ return node_id
206
+
207
+ # Insert new node
208
+ conn.execute("""
209
+ INSERT INTO Nodes (id, label, properties, created_at, updated_at)
210
+ VALUES (?, ?, ?, ?, ?)
211
+ """, (node_id, label, json.dumps(props), now_iso(), now_iso()))
212
+
213
+ return node_id
214
+
215
+ def create_relation(db_path: str, source_id: str, target_id: str, relation_type: str, properties: dict = None):
216
+ """
217
+ Draw an edge. Composite UNIQUE constraint prevents identical duplicates.
218
+ """
219
+ init_db(db_path)
220
+ props = properties or {}
221
+
222
+ with get_connection(db_path) as conn:
223
+ with write_transaction(conn):
224
+ conn.execute("""
225
+ INSERT INTO Edges (source_id, target_id, relation_type, properties, created_at)
226
+ VALUES (?, ?, ?, ?, ?)
227
+ ON CONFLICT(source_id, target_id, relation_type) DO UPDATE SET
228
+ properties = excluded.properties,
229
+ last_verified_at = ?
230
+ """, (source_id, target_id, relation_type, json.dumps(props), now_iso(), now_iso()))
231
+
232
+ def add_observation(db_path: str, node_id: str, observation: str):
233
+ """
234
+ Appends an observation directly into the 'observations' array in the properties JSON payload.
235
+ """
236
+ init_db(db_path)
237
+
238
+ with get_connection(db_path) as conn:
239
+ with write_transaction(conn):
240
+ row = conn.execute("SELECT properties FROM Nodes WHERE id = ? AND is_deleted = 0", (node_id,)).fetchone()
241
+ if not row:
242
+ raise ValueError(f"Node '{node_id}' not found or is deleted.")
243
+
244
+ props = json.loads(row[0]) if row[0] else {}
245
+ observations = props.get("observations", [])
246
+ observations.append(observation)
247
+ props["observations"] = observations
248
+
249
+ conn.execute("""
250
+ UPDATE Nodes
251
+ SET properties = ?, updated_at = ?
252
+ WHERE id = ?
253
+ """, (json.dumps(props), now_iso(), node_id))
254
+
255
+ def soft_delete_entity(db_path: str, node_id: str):
256
+ """
257
+ Soft-deletes a node by setting is_deleted=1.
258
+ """
259
+ init_db(db_path)
260
+ with get_connection(db_path) as conn:
261
+ with write_transaction(conn):
262
+ conn.execute("UPDATE Nodes SET is_deleted = 1, updated_at = ? WHERE id = ?", (now_iso(), node_id))
263
+
264
+ def delete_relation(db_path: str, source_id: str, target_id: str, relation_type: str):
265
+ """
266
+ Hard-deletes an edge.
267
+ """
268
+ init_db(db_path)
269
+ with get_connection(db_path) as conn:
270
+ with write_transaction(conn):
271
+ conn.execute("""
272
+ DELETE FROM Edges
273
+ WHERE source_id = ? AND target_id = ? AND relation_type = ?
274
+ """, (source_id, target_id, relation_type))
275
+
276
+ def read_graph(db_path: str) -> dict:
277
+ """
278
+ Exports the entire active graph topology.
279
+ """
280
+ init_db(db_path)
281
+ with get_connection(db_path) as conn:
282
+ nodes = []
283
+ for row in conn.execute("SELECT id, label, properties FROM Nodes WHERE is_deleted = 0").fetchall():
284
+ nodes.append({
285
+ "id": row[0],
286
+ "label": row[1],
287
+ "properties": json.loads(row[2]) if row[2] else {}
288
+ })
289
+
290
+ edges = []
291
+ for row in conn.execute("SELECT source_id, target_id, relation_type, properties FROM Edges").fetchall():
292
+ edges.append({
293
+ "source_id": row[0],
294
+ "target_id": row[1],
295
+ "relation_type": row[2],
296
+ "properties": json.loads(row[3]) if row[3] else {}
297
+ })
298
+
299
+ return {"nodes": nodes, "edges": edges}
300
+
301
+ # ---------------------------------------------------------------------------
302
+ # Serialization
303
+ # ---------------------------------------------------------------------------
304
+
305
+ def serialize_subgraph(db_path: str, central_node_id: str) -> str:
306
+ """
307
+ Converts a node's immediate neighborhood into an LLM-readable format.
308
+ Maximizes attention while minimizing token bloat.
309
+ """
310
+ init_db(db_path)
311
+ with get_connection(db_path) as conn:
312
+ node = conn.execute("""
313
+ SELECT label, properties, status, updated_at
314
+ FROM Nodes
315
+ WHERE id = ? AND is_deleted = 0
316
+ """, (central_node_id,)).fetchone()
317
+
318
+ if not node:
319
+ return f"Node '{central_node_id}' not found or deleted."
320
+
321
+ label, props_json, status, updated_at = node
322
+ props = json.loads(props_json) if props_json else {}
323
+
324
+ output = [
325
+ f"Entity: {central_node_id} ({label})",
326
+ f"Status: {status} | Last Updated: {updated_at}",
327
+ f"Metadata: {json.dumps(props, indent=2)}",
328
+ "Relationships:"
329
+ ]
330
+
331
+ edges = conn.execute("""
332
+ SELECT relation_type, target_id, properties
333
+ FROM Edges
334
+ WHERE source_id = ?
335
+ """, (central_node_id,)).fetchall()
336
+
337
+ if not edges:
338
+ output.append(" (None)")
339
+ else:
340
+ for rel_type, target, e_props in edges:
341
+ output.append(f" -[{rel_type}]-> {target} (Context: {e_props})")
342
+
343
+ # Also show incoming edges
344
+ incoming_edges = conn.execute("""
345
+ SELECT source_id, relation_type, properties
346
+ FROM Edges
347
+ WHERE target_id = ?
348
+ """, (central_node_id,)).fetchall()
349
+
350
+ if incoming_edges:
351
+ output.append("Incoming Relationships:")
352
+ for source, rel_type, e_props in incoming_edges:
353
+ output.append(f" {source} -[{rel_type}]-> (this) (Context: {e_props})")
354
+
355
+ return "\n".join(output)
File without changes
File without changes
@@ -0,0 +1,312 @@
1
+ import asyncio
2
+ import json
3
+ import os
4
+ from typing import List, Dict, Any
5
+ from mcp.server.models import InitializationOptions
6
+ import mcp.types as types
7
+ from mcp.server import NotificationOptions, Server
8
+ from mcp.server.stdio import stdio_server
9
+
10
+ from graph_memory.core import engine
11
+
12
+ # Default DB Path
13
+ DB_PATH = engine.get_db_path()
14
+
15
+ # Ensure DB is initialized
16
+ engine.init_db(DB_PATH)
17
+
18
+ server = Server("graph-memory")
19
+
20
+ @server.list_tools()
21
+ async def handle_list_tools() -> list[types.Tool]:
22
+ """
23
+ Exposes the 9 standard Anthropic MCP Memory Tool signatures.
24
+ This makes the package a "Drop-In Replacement" for the official memory server.
25
+ """
26
+ return [
27
+ types.Tool(
28
+ name="create_entities",
29
+ description="Create multiple new entities in the knowledge graph.",
30
+ inputSchema={
31
+ "type": "object",
32
+ "properties": {
33
+ "entities": {
34
+ "type": "array",
35
+ "items": {
36
+ "type": "object",
37
+ "properties": {
38
+ "name": {"type": "string", "description": "The name/ID of the entity"},
39
+ "entityType": {"type": "string", "description": "The type or label of the entity"},
40
+ "observations": {
41
+ "type": "array",
42
+ "items": {"type": "string"},
43
+ "description": "An array of observation strings about this entity"
44
+ }
45
+ },
46
+ "required": ["name", "entityType", "observations"]
47
+ }
48
+ }
49
+ },
50
+ "required": ["entities"]
51
+ }
52
+ ),
53
+ types.Tool(
54
+ name="create_relations",
55
+ description="Create multiple new relations between entities in the knowledge graph.",
56
+ inputSchema={
57
+ "type": "object",
58
+ "properties": {
59
+ "relations": {
60
+ "type": "array",
61
+ "items": {
62
+ "type": "object",
63
+ "properties": {
64
+ "from": {"type": "string", "description": "The name of the source entity"},
65
+ "to": {"type": "string", "description": "The name of the target entity"},
66
+ "relationType": {"type": "string", "description": "The type of relation"}
67
+ },
68
+ "required": ["from", "to", "relationType"]
69
+ }
70
+ }
71
+ },
72
+ "required": ["relations"]
73
+ }
74
+ ),
75
+ types.Tool(
76
+ name="add_observations",
77
+ description="Add new observations to existing entities in the knowledge graph.",
78
+ inputSchema={
79
+ "type": "object",
80
+ "properties": {
81
+ "observations": {
82
+ "type": "array",
83
+ "items": {
84
+ "type": "object",
85
+ "properties": {
86
+ "entityName": {"type": "string", "description": "The name of the entity"},
87
+ "contents": {
88
+ "type": "array",
89
+ "items": {"type": "string"},
90
+ "description": "An array of observation strings to add"
91
+ }
92
+ },
93
+ "required": ["entityName", "contents"]
94
+ }
95
+ }
96
+ },
97
+ "required": ["observations"]
98
+ }
99
+ ),
100
+ types.Tool(
101
+ name="delete_entities",
102
+ description="Delete multiple entities and their associated relations from the knowledge graph.",
103
+ inputSchema={
104
+ "type": "object",
105
+ "properties": {
106
+ "entityNames": {
107
+ "type": "array",
108
+ "items": {"type": "string"},
109
+ "description": "An array of entity names to delete"
110
+ }
111
+ },
112
+ "required": ["entityNames"]
113
+ }
114
+ ),
115
+ types.Tool(
116
+ name="delete_observations",
117
+ description="Delete specific observations from entities in the knowledge graph.",
118
+ inputSchema={
119
+ "type": "object",
120
+ "properties": {
121
+ "deletions": {
122
+ "type": "array",
123
+ "items": {
124
+ "type": "object",
125
+ "properties": {
126
+ "entityName": {"type": "string", "description": "The name of the entity"},
127
+ "observations": {
128
+ "type": "array",
129
+ "items": {"type": "string"},
130
+ "description": "An array of observation strings to delete"
131
+ }
132
+ },
133
+ "required": ["entityName", "observations"]
134
+ }
135
+ }
136
+ },
137
+ "required": ["deletions"]
138
+ }
139
+ ),
140
+ types.Tool(
141
+ name="delete_relations",
142
+ description="Delete multiple relations from the knowledge graph.",
143
+ inputSchema={
144
+ "type": "object",
145
+ "properties": {
146
+ "relations": {
147
+ "type": "array",
148
+ "items": {
149
+ "type": "object",
150
+ "properties": {
151
+ "from": {"type": "string", "description": "The name of the source entity"},
152
+ "to": {"type": "string", "description": "The name of the target entity"},
153
+ "relationType": {"type": "string", "description": "The type of relation"}
154
+ },
155
+ "required": ["from", "to", "relationType"]
156
+ }
157
+ }
158
+ },
159
+ "required": ["relations"]
160
+ }
161
+ ),
162
+ types.Tool(
163
+ name="read_graph",
164
+ description="Read the entire knowledge graph.",
165
+ inputSchema={
166
+ "type": "object",
167
+ "properties": {},
168
+ }
169
+ ),
170
+ types.Tool(
171
+ name="search_nodes",
172
+ description="Search for nodes in the knowledge graph based on a query.",
173
+ inputSchema={
174
+ "type": "object",
175
+ "properties": {
176
+ "query": {"type": "string", "description": "The search query to match against entity names, types, and observation content."}
177
+ },
178
+ "required": ["query"]
179
+ }
180
+ ),
181
+ types.Tool(
182
+ name="open_nodes",
183
+ description="Open specific nodes in the knowledge graph.",
184
+ inputSchema={
185
+ "type": "object",
186
+ "properties": {
187
+ "names": {
188
+ "type": "array",
189
+ "items": {"type": "string"},
190
+ "description": "An array of entity names to retrieve"
191
+ }
192
+ },
193
+ "required": ["names"]
194
+ }
195
+ ),
196
+ ]
197
+
198
+ @server.call_tool()
199
+ async def handle_call_tool(
200
+ name: str, arguments: dict | None
201
+ ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
202
+ """
203
+ Handle tool executions, mapping Anthropic's standard API to our SQLite backend.
204
+ """
205
+ if not arguments:
206
+ arguments = {}
207
+
208
+ try:
209
+ if name == "create_entities":
210
+ entities = arguments.get("entities", [])
211
+ for ent in entities:
212
+ engine.get_or_create_node(
213
+ DB_PATH,
214
+ node_id=ent["name"],
215
+ label=ent["entityType"],
216
+ properties={"observations": ent.get("observations", []), "type": ent["entityType"]}
217
+ )
218
+ return [types.TextContent(type="text", text=f"Created {len(entities)} entities successfully.")]
219
+
220
+ elif name == "create_relations":
221
+ relations = arguments.get("relations", [])
222
+ for rel in relations:
223
+ engine.create_relation(
224
+ DB_PATH,
225
+ source_id=rel["from"],
226
+ target_id=rel["to"],
227
+ relation_type=rel["relationType"]
228
+ )
229
+ return [types.TextContent(type="text", text=f"Created {len(relations)} relations successfully.")]
230
+
231
+ elif name == "add_observations":
232
+ observations = arguments.get("observations", [])
233
+ for obs in observations:
234
+ for content in obs.get("contents", []):
235
+ engine.add_observation(DB_PATH, obs["entityName"], content)
236
+ return [types.TextContent(type="text", text=f"Added observations successfully.")]
237
+
238
+ elif name == "delete_entities":
239
+ entityNames = arguments.get("entityNames", [])
240
+ for name in entityNames:
241
+ engine.soft_delete_entity(DB_PATH, name)
242
+ return [types.TextContent(type="text", text=f"Soft deleted {len(entityNames)} entities successfully.")]
243
+
244
+ elif name == "delete_observations":
245
+ deletions = arguments.get("deletions", [])
246
+ for deletion in deletions:
247
+ node_id = deletion["entityName"]
248
+ obs_to_delete = deletion.get("observations", [])
249
+
250
+ # Fetch current properties manually to perform deletion
251
+ with engine.get_connection(DB_PATH) as conn:
252
+ row = conn.execute("SELECT properties FROM Nodes WHERE id = ? AND is_deleted = 0", (node_id,)).fetchone()
253
+ if row:
254
+ props = json.loads(row[0]) if row[0] else {}
255
+ current_obs = props.get("observations", [])
256
+ new_obs = [o for o in current_obs if o not in obs_to_delete]
257
+ props["observations"] = new_obs
258
+ with engine.write_transaction(conn):
259
+ conn.execute("UPDATE Nodes SET properties = ?, updated_at = ? WHERE id = ?",
260
+ (json.dumps(props), engine.now_iso(), node_id))
261
+
262
+ return [types.TextContent(type="text", text=f"Deleted observations successfully.")]
263
+
264
+ elif name == "delete_relations":
265
+ relations = arguments.get("relations", [])
266
+ for rel in relations:
267
+ engine.delete_relation(
268
+ DB_PATH,
269
+ source_id=rel["from"],
270
+ target_id=rel["to"],
271
+ relation_type=rel["relationType"]
272
+ )
273
+ return [types.TextContent(type="text", text=f"Deleted {len(relations)} relations successfully.")]
274
+
275
+ elif name == "read_graph":
276
+ graph = engine.read_graph(DB_PATH)
277
+ return [types.TextContent(type="text", text=json.dumps(graph, indent=2))]
278
+
279
+ elif name == "search_nodes":
280
+ query = arguments.get("query", "")
281
+ results = engine.search_nodes(DB_PATH, query)
282
+ return [types.TextContent(type="text", text=json.dumps(results, indent=2))]
283
+
284
+ elif name == "open_nodes":
285
+ names = arguments.get("names", [])
286
+ outputs = []
287
+ for name in names:
288
+ subgraph = engine.serialize_subgraph(DB_PATH, name)
289
+ outputs.append(subgraph)
290
+ return [types.TextContent(type="text", text="\n---\n".join(outputs))]
291
+
292
+ else:
293
+ raise ValueError(f"Unknown tool: {name}")
294
+
295
+ except Exception as e:
296
+ return [types.TextContent(type="text", text=f"Error executing {name}: {str(e)}")]
297
+
298
+ async def run_mcp_server():
299
+ """Runs the MCP server using stdio."""
300
+ options = server.create_initialization_options()
301
+ async with stdio_server() as (read_stream, write_stream):
302
+ await server.run(
303
+ read_stream,
304
+ write_stream,
305
+ options
306
+ )
307
+
308
+ def main():
309
+ asyncio.run(run_mcp_server())
310
+
311
+ if __name__ == "__main__":
312
+ main()