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.
- epistemic_graph_memory-1.2.0.dist-info/METADATA +154 -0
- epistemic_graph_memory-1.2.0.dist-info/RECORD +12 -0
- epistemic_graph_memory-1.2.0.dist-info/WHEEL +5 -0
- epistemic_graph_memory-1.2.0.dist-info/entry_points.txt +3 -0
- epistemic_graph_memory-1.2.0.dist-info/top_level.txt +1 -0
- graph_memory/__init__.py +0 -0
- graph_memory/cli.py +145 -0
- graph_memory/core/__init__.py +0 -0
- graph_memory/core/engine.py +355 -0
- graph_memory/integrations/__init__.py +0 -0
- graph_memory/mcp/__init__.py +0 -0
- graph_memory/mcp/server.py +312 -0
|
@@ -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 @@
|
|
|
1
|
+
graph_memory
|
graph_memory/__init__.py
ADDED
|
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()
|