@contextableai/openclaw-memory-rebac 0.1.0
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.
- package/README.md +464 -0
- package/authorization.ts +191 -0
- package/backend.ts +176 -0
- package/backends/backends.json +3 -0
- package/backends/graphiti.defaults.json +8 -0
- package/backends/graphiti.test.ts +292 -0
- package/backends/graphiti.ts +345 -0
- package/backends/registry.ts +36 -0
- package/bin/rebac-mem.ts +144 -0
- package/cli.ts +418 -0
- package/config.ts +141 -0
- package/docker/docker-compose.yml +17 -0
- package/docker/graphiti/Dockerfile +35 -0
- package/docker/graphiti/config_overlay.py +44 -0
- package/docker/graphiti/docker-compose.yml +101 -0
- package/docker/graphiti/graphiti_overlay.py +141 -0
- package/docker/graphiti/startup.py +222 -0
- package/docker/spicedb/docker-compose.yml +79 -0
- package/index.ts +711 -0
- package/openclaw.plugin.json +118 -0
- package/package.json +70 -0
- package/plugin.defaults.json +12 -0
- package/schema.zed +23 -0
- package/search.ts +139 -0
- package/spicedb.ts +355 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
###############################################################################
|
|
2
|
+
# openclaw-memory-rebac — Graphiti FastAPI REST server stack (standalone)
|
|
3
|
+
#
|
|
4
|
+
# Services:
|
|
5
|
+
# neo4j — graph database
|
|
6
|
+
# graphiti — Graphiti FastAPI REST server with extended config
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# cp .env.example .env # configure LLM, embedder, reranker
|
|
10
|
+
# docker compose up -d
|
|
11
|
+
#
|
|
12
|
+
# Graphiti endpoint: http://graphiti:8000 (internal to Docker network only)
|
|
13
|
+
#
|
|
14
|
+
# LLM Configuration:
|
|
15
|
+
# Each AI component (LLM, embedder, reranker) can point to a different
|
|
16
|
+
# service. See .env for all available configuration options.
|
|
17
|
+
###############################################################################
|
|
18
|
+
|
|
19
|
+
name: openclaw-graphiti
|
|
20
|
+
|
|
21
|
+
services:
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Neo4j — graph store for Graphiti
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
neo4j:
|
|
27
|
+
image: neo4j:5.26.2
|
|
28
|
+
restart: unless-stopped
|
|
29
|
+
expose:
|
|
30
|
+
- "7687"
|
|
31
|
+
- "7474"
|
|
32
|
+
environment:
|
|
33
|
+
NEO4J_AUTH: ${NEO4J_USER:-neo4j}/${NEO4J_PASSWORD:-graphiti_pw}
|
|
34
|
+
volumes:
|
|
35
|
+
- neo4j_data:/data
|
|
36
|
+
healthcheck:
|
|
37
|
+
test: ["CMD-SHELL", "wget -q --spider http://localhost:7474 || exit 1"]
|
|
38
|
+
interval: 10s
|
|
39
|
+
timeout: 10s
|
|
40
|
+
retries: 15
|
|
41
|
+
start_period: 30s
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Graphiti FastAPI REST server
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
graphiti:
|
|
47
|
+
build:
|
|
48
|
+
context: .
|
|
49
|
+
dockerfile: Dockerfile
|
|
50
|
+
restart: unless-stopped
|
|
51
|
+
expose:
|
|
52
|
+
- "8000"
|
|
53
|
+
environment:
|
|
54
|
+
# -- Graph database (Neo4j) --
|
|
55
|
+
NEO4J_URI: bolt://neo4j:7687
|
|
56
|
+
NEO4J_USER: ${NEO4J_USER:-neo4j}
|
|
57
|
+
NEO4J_PASSWORD: ${NEO4J_PASSWORD:-graphiti_pw}
|
|
58
|
+
|
|
59
|
+
# -- Graphiti concurrency --
|
|
60
|
+
# build_indices_and_constraints fires 29 index queries concurrently
|
|
61
|
+
# (default semaphore limit = 20). Neo4j can't handle that many
|
|
62
|
+
# concurrent DDL ops and drops connections. Serialize them.
|
|
63
|
+
SEMAPHORE_LIMIT: 3
|
|
64
|
+
|
|
65
|
+
# -- LLM (entity extraction) --
|
|
66
|
+
OPENAI_API_KEY: ${LLM_API_KEY:?Set LLM_API_KEY in .env}
|
|
67
|
+
OPENAI_BASE_URL: ${LLM_BASE_URL:-}
|
|
68
|
+
MODEL_NAME: ${LLM_MODEL:-gpt-4o-mini}
|
|
69
|
+
|
|
70
|
+
# -- Embedder --
|
|
71
|
+
EMBEDDING_MODEL_NAME: ${EMBEDDING_MODEL:-text-embedding-3-small}
|
|
72
|
+
EMBEDDING_BASE_URL: ${EMBEDDING_BASE_URL:-}
|
|
73
|
+
EMBEDDING_API_KEY: ${EMBEDDING_API_KEY:-}
|
|
74
|
+
EMBEDDING_DIM: ${EMBEDDING_DIM:-}
|
|
75
|
+
|
|
76
|
+
# -- Reranker / cross-encoder --
|
|
77
|
+
# Default: "bge" runs BAAI/bge-reranker-v2-m3 locally (no API needed)
|
|
78
|
+
# Set to "openai" to use a remote reranker API instead
|
|
79
|
+
RERANKER_PROVIDER: ${RERANKER_PROVIDER:-bge}
|
|
80
|
+
RERANKER_MODEL: ${RERANKER_MODEL:-}
|
|
81
|
+
RERANKER_BASE_URL: ${RERANKER_BASE_URL:-}
|
|
82
|
+
RERANKER_API_KEY: ${RERANKER_API_KEY:-}
|
|
83
|
+
|
|
84
|
+
# -- Additional provider keys (optional) --
|
|
85
|
+
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
|
86
|
+
GOOGLE_API_KEY: ${GOOGLE_API_KEY:-}
|
|
87
|
+
GROQ_API_KEY: ${GROQ_API_KEY:-}
|
|
88
|
+
depends_on:
|
|
89
|
+
neo4j:
|
|
90
|
+
condition: service_healthy
|
|
91
|
+
extra_hosts:
|
|
92
|
+
- "host.docker.internal:host-gateway"
|
|
93
|
+
healthcheck:
|
|
94
|
+
test: ["CMD", "curl", "-sf", "http://localhost:8000/healthcheck"]
|
|
95
|
+
interval: 10s
|
|
96
|
+
timeout: 5s
|
|
97
|
+
retries: 5
|
|
98
|
+
start_period: 15s
|
|
99
|
+
|
|
100
|
+
volumes:
|
|
101
|
+
neo4j_data:
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Extended Graphiti initialization with per-component client configuration.
|
|
3
|
+
|
|
4
|
+
Defines OpenClawGraphiti — a subclass of the base Graphiti class that:
|
|
5
|
+
1. Properly forwards all constructor params (embedder, cross_encoder)
|
|
6
|
+
2. Adds the CRUD methods the FastAPI routes require
|
|
7
|
+
|
|
8
|
+
We bypass ZepGraphiti because its __init__ only forwards (uri, user,
|
|
9
|
+
password, llm_client) to super(), silently dropping embedder and
|
|
10
|
+
cross_encoder. This causes all embedding calls to use the hardcoded
|
|
11
|
+
default model (text-embedding-3-small) instead of the configured one.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
|
|
16
|
+
from graphiti_core import Graphiti
|
|
17
|
+
from graphiti_core.edges import EntityEdge
|
|
18
|
+
from graphiti_core.errors import (
|
|
19
|
+
EdgeNotFoundError,
|
|
20
|
+
GroupsEdgesNotFoundError,
|
|
21
|
+
NodeNotFoundError,
|
|
22
|
+
)
|
|
23
|
+
from graphiti_core.llm_client.config import LLMConfig
|
|
24
|
+
from graphiti_core.llm_client.openai_generic_client import OpenAIGenericClient
|
|
25
|
+
from graphiti_core.embedder.openai import OpenAIEmbedder, OpenAIEmbedderConfig
|
|
26
|
+
from graphiti_core.nodes import EntityNode, EpisodicNode
|
|
27
|
+
|
|
28
|
+
from fastapi import HTTPException
|
|
29
|
+
|
|
30
|
+
from graph_service.config_overlay import ExtendedSettings
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class OpenClawGraphiti(Graphiti):
|
|
36
|
+
"""Graphiti subclass with server-specific CRUD methods.
|
|
37
|
+
|
|
38
|
+
Unlike ZepGraphiti, this properly forwards ALL constructor params
|
|
39
|
+
(including embedder and cross_encoder) to the base Graphiti class,
|
|
40
|
+
so they are wired into GraphitiClients at construction time.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
async def get_entity_edge(self, uuid: str):
|
|
44
|
+
try:
|
|
45
|
+
edge = await EntityEdge.get_by_uuid(self.driver, uuid)
|
|
46
|
+
return edge
|
|
47
|
+
except EdgeNotFoundError as e:
|
|
48
|
+
raise HTTPException(status_code=404, detail=e.message) from e
|
|
49
|
+
|
|
50
|
+
async def delete_entity_edge(self, uuid: str):
|
|
51
|
+
try:
|
|
52
|
+
edge = await EntityEdge.get_by_uuid(self.driver, uuid)
|
|
53
|
+
await edge.delete(self.driver)
|
|
54
|
+
except EdgeNotFoundError as e:
|
|
55
|
+
raise HTTPException(status_code=404, detail=e.message) from e
|
|
56
|
+
|
|
57
|
+
async def delete_group(self, group_id: str):
|
|
58
|
+
try:
|
|
59
|
+
edges = await EntityEdge.get_by_group_ids(self.driver, [group_id])
|
|
60
|
+
except GroupsEdgesNotFoundError:
|
|
61
|
+
logger.warning(f"No edges found for group {group_id}")
|
|
62
|
+
edges = []
|
|
63
|
+
|
|
64
|
+
nodes = await EntityNode.get_by_group_ids(self.driver, [group_id])
|
|
65
|
+
episodes = await EpisodicNode.get_by_group_ids(self.driver, [group_id])
|
|
66
|
+
|
|
67
|
+
for edge in edges:
|
|
68
|
+
await edge.delete(self.driver)
|
|
69
|
+
for node in nodes:
|
|
70
|
+
await node.delete(self.driver)
|
|
71
|
+
for episode in episodes:
|
|
72
|
+
await episode.delete(self.driver)
|
|
73
|
+
|
|
74
|
+
async def delete_episodic_node(self, uuid: str):
|
|
75
|
+
try:
|
|
76
|
+
episode = await EpisodicNode.get_by_uuid(self.driver, uuid)
|
|
77
|
+
await episode.delete(self.driver)
|
|
78
|
+
except NodeNotFoundError as e:
|
|
79
|
+
raise HTTPException(status_code=404, detail=e.message) from e
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _create_reranker(settings: ExtendedSettings, llm_client):
|
|
83
|
+
"""Create the appropriate reranker based on RERANKER_PROVIDER."""
|
|
84
|
+
if settings.reranker_provider == "openai":
|
|
85
|
+
from graphiti_core.cross_encoder.openai_reranker_client import OpenAIRerankerClient
|
|
86
|
+
|
|
87
|
+
reranker_config = LLMConfig(
|
|
88
|
+
api_key=settings.reranker_api_key or settings.openai_api_key,
|
|
89
|
+
base_url=settings.reranker_base_url or settings.openai_base_url,
|
|
90
|
+
)
|
|
91
|
+
if settings.reranker_model:
|
|
92
|
+
reranker_config.model = settings.reranker_model
|
|
93
|
+
return OpenAIRerankerClient(client=llm_client, config=reranker_config)
|
|
94
|
+
|
|
95
|
+
# Default: BGE reranker (runs locally via sentence-transformers, no API needed)
|
|
96
|
+
from graphiti_core.cross_encoder.bge_reranker_client import BGERerankerClient
|
|
97
|
+
|
|
98
|
+
return BGERerankerClient()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def create_graphiti(settings: ExtendedSettings) -> OpenClawGraphiti:
|
|
102
|
+
"""Create an OpenClawGraphiti instance with per-component client configuration."""
|
|
103
|
+
|
|
104
|
+
# -- LLM client (entity extraction) --
|
|
105
|
+
llm_config = LLMConfig(
|
|
106
|
+
api_key=settings.openai_api_key,
|
|
107
|
+
base_url=settings.openai_base_url,
|
|
108
|
+
)
|
|
109
|
+
if settings.model_name:
|
|
110
|
+
llm_config.model = settings.model_name
|
|
111
|
+
llm_config.small_model = settings.model_name
|
|
112
|
+
llm_client = OpenAIGenericClient(config=llm_config)
|
|
113
|
+
|
|
114
|
+
# -- Embedder --
|
|
115
|
+
embedder_api_key = settings.embedding_api_key or settings.openai_api_key
|
|
116
|
+
embedder_base_url = settings.embedding_base_url or settings.openai_base_url
|
|
117
|
+
embedder_kwargs = {
|
|
118
|
+
"api_key": embedder_api_key,
|
|
119
|
+
"base_url": embedder_base_url,
|
|
120
|
+
}
|
|
121
|
+
if settings.embedding_model_name:
|
|
122
|
+
embedder_kwargs["embedding_model"] = settings.embedding_model_name
|
|
123
|
+
if settings.embedding_dim is not None:
|
|
124
|
+
embedder_kwargs["embedding_dim"] = settings.embedding_dim
|
|
125
|
+
embedder_config = OpenAIEmbedderConfig(**embedder_kwargs)
|
|
126
|
+
embedder = OpenAIEmbedder(config=embedder_config)
|
|
127
|
+
|
|
128
|
+
# -- Reranker / cross-encoder --
|
|
129
|
+
reranker = _create_reranker(settings, llm_client)
|
|
130
|
+
|
|
131
|
+
# -- Construct with all params forwarded to base Graphiti --
|
|
132
|
+
client = OpenClawGraphiti(
|
|
133
|
+
uri=settings.neo4j_uri,
|
|
134
|
+
user=settings.neo4j_user,
|
|
135
|
+
password=settings.neo4j_password,
|
|
136
|
+
llm_client=llm_client,
|
|
137
|
+
embedder=embedder,
|
|
138
|
+
cross_encoder=reranker,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return client
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom startup that configures the Graphiti FastAPI server to use our
|
|
3
|
+
extended per-component embedder/reranker settings before launching uvicorn.
|
|
4
|
+
|
|
5
|
+
Uses FastAPI's app.dependency_overrides to replace get_graphiti —
|
|
6
|
+
the proper mechanism that works with Annotated[..., Depends()] captures.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import importlib
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
from graph_service.config_overlay import ExtendedSettings
|
|
14
|
+
from graph_service.graphiti_overlay import create_graphiti
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
MAX_RETRIES = 5
|
|
19
|
+
RETRY_BASE_DELAY = 3 # seconds, doubles each retry
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def patch():
|
|
23
|
+
"""
|
|
24
|
+
Override graph_service modules so the app uses ExtendedSettings
|
|
25
|
+
(with per-component embedder/reranker config) instead of the base
|
|
26
|
+
Settings class.
|
|
27
|
+
"""
|
|
28
|
+
settings = ExtendedSettings()
|
|
29
|
+
|
|
30
|
+
# -- Patch Settings class on config module --
|
|
31
|
+
# The @lru_cache get_settings() does `return Settings()`.
|
|
32
|
+
# Replacing the class on the module + clearing cache makes it
|
|
33
|
+
# return our ExtendedSettings (which has all base fields plus extras).
|
|
34
|
+
config_mod = importlib.import_module("graph_service.config")
|
|
35
|
+
config_mod.Settings = ExtendedSettings
|
|
36
|
+
config_mod.get_settings.cache_clear()
|
|
37
|
+
|
|
38
|
+
# -- Create the singleton client ONCE (loads BGE reranker weights) --
|
|
39
|
+
# Used for both index initialization and per-request dependency injection.
|
|
40
|
+
# Upstream get_graphiti creates/closes a client per-request, but POST
|
|
41
|
+
# /messages queues background work via AsyncWorker that outlives the
|
|
42
|
+
# request scope. A process-lifetime singleton avoids "Driver closed" errors.
|
|
43
|
+
singleton_client = create_graphiti(settings)
|
|
44
|
+
|
|
45
|
+
# -- Patch initialize_graphiti on upstream modules --
|
|
46
|
+
# main.py does `from graph_service.zep_graphiti import initialize_graphiti`
|
|
47
|
+
# creating a local binding we must also replace.
|
|
48
|
+
zep_mod = importlib.import_module("graph_service.zep_graphiti")
|
|
49
|
+
|
|
50
|
+
async def patched_initialize_graphiti(s=None):
|
|
51
|
+
"""Initialize graph DB indices with retry for Neo4j readiness."""
|
|
52
|
+
for attempt in range(1, MAX_RETRIES + 1):
|
|
53
|
+
try:
|
|
54
|
+
await singleton_client.build_indices_and_constraints()
|
|
55
|
+
logger.info("Graph indices built successfully (attempt %d)", attempt)
|
|
56
|
+
return
|
|
57
|
+
except Exception as e:
|
|
58
|
+
logger.warning(
|
|
59
|
+
"build_indices_and_constraints failed (attempt %d/%d): %s",
|
|
60
|
+
attempt, MAX_RETRIES, e,
|
|
61
|
+
)
|
|
62
|
+
if attempt == MAX_RETRIES:
|
|
63
|
+
logger.error("All %d attempts failed, giving up", MAX_RETRIES)
|
|
64
|
+
raise
|
|
65
|
+
delay = RETRY_BASE_DELAY * (2 ** (attempt - 1))
|
|
66
|
+
logger.info("Retrying in %ds...", delay)
|
|
67
|
+
await asyncio.sleep(delay)
|
|
68
|
+
|
|
69
|
+
zep_mod.initialize_graphiti = patched_initialize_graphiti
|
|
70
|
+
|
|
71
|
+
main_mod = importlib.import_module("graph_service.main")
|
|
72
|
+
main_mod.initialize_graphiti = patched_initialize_graphiti
|
|
73
|
+
|
|
74
|
+
# -- Override get_graphiti via FastAPI dependency_overrides --
|
|
75
|
+
# This is the proper mechanism: app.dependency_overrides replaces
|
|
76
|
+
# the function that Depends(get_graphiti) captured at import time.
|
|
77
|
+
# We must import the app AFTER patching initialize_graphiti above
|
|
78
|
+
# (the app's lifespan calls initialize_graphiti on startup).
|
|
79
|
+
from graph_service.main import app
|
|
80
|
+
|
|
81
|
+
original_get_graphiti = zep_mod.get_graphiti
|
|
82
|
+
|
|
83
|
+
async def patched_get_graphiti(settings_dep=None):
|
|
84
|
+
"""Yield the long-lived Graphiti client."""
|
|
85
|
+
yield singleton_client
|
|
86
|
+
|
|
87
|
+
app.dependency_overrides[original_get_graphiti] = patched_get_graphiti
|
|
88
|
+
|
|
89
|
+
# -- Fix upstream AsyncWorker crash-on-error bug --
|
|
90
|
+
# The worker loop only catches CancelledError; any other exception from
|
|
91
|
+
# add_episode() kills the worker silently and no more jobs are processed.
|
|
92
|
+
ingest_mod = importlib.import_module("graph_service.routers.ingest")
|
|
93
|
+
|
|
94
|
+
async def resilient_worker(self):
|
|
95
|
+
"""Worker loop with exception logging and recovery."""
|
|
96
|
+
while True:
|
|
97
|
+
try:
|
|
98
|
+
job = await self.queue.get()
|
|
99
|
+
logger.info("AsyncWorker processing job (queue size: %d)", self.queue.qsize())
|
|
100
|
+
await job()
|
|
101
|
+
except asyncio.CancelledError:
|
|
102
|
+
break
|
|
103
|
+
except Exception:
|
|
104
|
+
logger.exception("AsyncWorker job failed")
|
|
105
|
+
|
|
106
|
+
ingest_mod.AsyncWorker.worker = resilient_worker
|
|
107
|
+
|
|
108
|
+
# -- Fix Neo4j CypherTypeError for nested attribute maps --
|
|
109
|
+
# graphiti-core does `entity_data.update(node.attributes or {})` for nodes
|
|
110
|
+
# and `edge_data.update(edge.attributes or {})` for edges, merging raw
|
|
111
|
+
# LLM-extracted attributes directly into Neo4j properties.
|
|
112
|
+
# Some LLMs (e.g., qwen2.5 via Ollama) return nested dicts/lists for
|
|
113
|
+
# attributes, which Neo4j rejects: "Property values can only be of
|
|
114
|
+
# primitive types." Sanitize both entity nodes AND entity edges.
|
|
115
|
+
import json
|
|
116
|
+
|
|
117
|
+
bulk_mod = importlib.import_module("graphiti_core.utils.bulk_utils")
|
|
118
|
+
original_bulk_add = bulk_mod.add_nodes_and_edges_bulk
|
|
119
|
+
|
|
120
|
+
def _sanitize_attributes(attrs):
|
|
121
|
+
"""Flatten non-primitive attribute values to JSON strings for Neo4j."""
|
|
122
|
+
if not attrs:
|
|
123
|
+
return attrs
|
|
124
|
+
sanitized = {}
|
|
125
|
+
for k, v in attrs.items():
|
|
126
|
+
if isinstance(v, (dict, list, set, tuple)):
|
|
127
|
+
sanitized[k] = json.dumps(v, default=str)
|
|
128
|
+
else:
|
|
129
|
+
sanitized[k] = v
|
|
130
|
+
return sanitized
|
|
131
|
+
|
|
132
|
+
async def patched_bulk_add(driver, episodic_nodes, episodic_edges,
|
|
133
|
+
entity_nodes, entity_edges, embedder):
|
|
134
|
+
for node in entity_nodes:
|
|
135
|
+
if node.attributes:
|
|
136
|
+
node.attributes = _sanitize_attributes(node.attributes)
|
|
137
|
+
for edge in entity_edges:
|
|
138
|
+
if edge.attributes:
|
|
139
|
+
edge.attributes = _sanitize_attributes(edge.attributes)
|
|
140
|
+
return await original_bulk_add(
|
|
141
|
+
driver, episodic_nodes, episodic_edges,
|
|
142
|
+
entity_nodes, entity_edges, embedder,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
bulk_mod.add_nodes_and_edges_bulk = patched_bulk_add
|
|
146
|
+
|
|
147
|
+
# Also patch the local binding in graphiti.py (uses `from ... import`)
|
|
148
|
+
graphiti_mod = importlib.import_module("graphiti_core.graphiti")
|
|
149
|
+
graphiti_mod.add_nodes_and_edges_bulk = patched_bulk_add
|
|
150
|
+
|
|
151
|
+
# -- Fix TypeError in extract_edges when LLM returns None for node indices --
|
|
152
|
+
# graphiti-core does `if not (-1 < source_idx < len(nodes) and -1 < target_idx < len(nodes))`
|
|
153
|
+
# which crashes with TypeError if the LLM returns None for an index.
|
|
154
|
+
#
|
|
155
|
+
# Primary fix: filter bad edges at model-parse level so valid edges from the same
|
|
156
|
+
# episode are preserved. Only applies when the Edge model has int fields (old
|
|
157
|
+
# index-based validation); a no-op for newer name-based validation.
|
|
158
|
+
# Fallback: catch TypeError from the whole function (loses all edges for that episode).
|
|
159
|
+
edge_ops_mod = importlib.import_module(
|
|
160
|
+
"graphiti_core.utils.maintenance.edge_operations"
|
|
161
|
+
)
|
|
162
|
+
original_extract_edges = edge_ops_mod.extract_edges
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
_ep_mod = importlib.import_module("graphiti_core.prompts.extract_edges")
|
|
166
|
+
_OrigExtractedEdges = _ep_mod.ExtractedEdges
|
|
167
|
+
_OrigEdge = _ep_mod.Edge
|
|
168
|
+
|
|
169
|
+
# Collect Optional[int] fields (present in old index-based Edge model)
|
|
170
|
+
_int_fields = []
|
|
171
|
+
for _fname, _finfo in _OrigEdge.model_fields.items():
|
|
172
|
+
_ann = _finfo.annotation
|
|
173
|
+
if _ann is int:
|
|
174
|
+
_int_fields.append(_fname)
|
|
175
|
+
elif hasattr(_ann, "__args__") and int in _ann.__args__:
|
|
176
|
+
_int_fields.append(_fname)
|
|
177
|
+
|
|
178
|
+
if _int_fields:
|
|
179
|
+
_orig_mv = _OrigExtractedEdges.model_validate
|
|
180
|
+
|
|
181
|
+
@classmethod # type: ignore[misc]
|
|
182
|
+
def _filtered_model_validate(cls, obj, *a, **kw):
|
|
183
|
+
result = _orig_mv.__func__(cls, obj, *a, **kw)
|
|
184
|
+
if result.edges:
|
|
185
|
+
valid = [e for e in result.edges if all(getattr(e, f) is not None for f in _int_fields)]
|
|
186
|
+
dropped = len(result.edges) - len(valid)
|
|
187
|
+
if dropped:
|
|
188
|
+
logger.warning("extract_edges: filtered %d edge(s) with None index fields", dropped)
|
|
189
|
+
result.edges = valid
|
|
190
|
+
return result
|
|
191
|
+
|
|
192
|
+
_OrigExtractedEdges.model_validate = _filtered_model_validate
|
|
193
|
+
logger.info("Patched ExtractedEdges.model_validate to filter None int fields: %s", _int_fields)
|
|
194
|
+
else:
|
|
195
|
+
logger.info("ExtractedEdges uses name-based validation — None-index filter not needed")
|
|
196
|
+
except Exception as _patch_err:
|
|
197
|
+
logger.warning("Could not patch ExtractedEdges for None-index filtering: %s", _patch_err)
|
|
198
|
+
|
|
199
|
+
# Fallback: catch any remaining TypeError from the whole function
|
|
200
|
+
async def safe_extract_edges(*args, **kwargs):
|
|
201
|
+
try:
|
|
202
|
+
return await original_extract_edges(*args, **kwargs)
|
|
203
|
+
except TypeError as e:
|
|
204
|
+
if "not supported between instances" in str(e):
|
|
205
|
+
logger.warning("extract_edges skipped due to LLM output issue: %s", e)
|
|
206
|
+
return []
|
|
207
|
+
raise
|
|
208
|
+
|
|
209
|
+
edge_ops_mod.extract_edges = safe_extract_edges
|
|
210
|
+
# Patch the local binding in graphiti.py
|
|
211
|
+
graphiti_mod.extract_edges = safe_extract_edges
|
|
212
|
+
|
|
213
|
+
return app
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
if __name__ == "__main__":
|
|
217
|
+
import uvicorn
|
|
218
|
+
|
|
219
|
+
app = patch()
|
|
220
|
+
|
|
221
|
+
port = ExtendedSettings().port
|
|
222
|
+
uvicorn.run(app, host="0.0.0.0", port=port)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
###############################################################################
|
|
2
|
+
# openclaw-memory-rebac — SpiceDB authorization stack (standalone)
|
|
3
|
+
#
|
|
4
|
+
# Services:
|
|
5
|
+
# postgres — SpiceDB backing store (PostgreSQL 16)
|
|
6
|
+
# spicedb-migrate — one-shot schema migration
|
|
7
|
+
# spicedb — authorization engine (gRPC :50051, HTTP :8080)
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# cp .env.example .env # set SPICEDB_PRESHARED_KEY
|
|
11
|
+
# docker compose up -d
|
|
12
|
+
#
|
|
13
|
+
# SpiceDB endpoint: localhost:50051 (insecure by default)
|
|
14
|
+
###############################################################################
|
|
15
|
+
|
|
16
|
+
name: openclaw-spicedb
|
|
17
|
+
|
|
18
|
+
services:
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# PostgreSQL — SpiceDB backing store
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
postgres:
|
|
24
|
+
image: postgres:16-alpine
|
|
25
|
+
restart: unless-stopped
|
|
26
|
+
environment:
|
|
27
|
+
POSTGRES_USER: spicedb
|
|
28
|
+
POSTGRES_PASSWORD: ${SPICEDB_POSTGRES_PASSWORD:-spicedb}
|
|
29
|
+
POSTGRES_DB: spicedb
|
|
30
|
+
volumes:
|
|
31
|
+
- graphiti_spicedb_postgres_data:/var/lib/postgresql/data
|
|
32
|
+
healthcheck:
|
|
33
|
+
test: ["CMD-SHELL", "pg_isready -U spicedb"]
|
|
34
|
+
interval: 5s
|
|
35
|
+
retries: 10
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# SpiceDB migration (one-shot)
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
spicedb-migrate:
|
|
41
|
+
image: authzed/spicedb:latest
|
|
42
|
+
command: migrate head
|
|
43
|
+
environment:
|
|
44
|
+
SPICEDB_DATASTORE_ENGINE: postgres
|
|
45
|
+
SPICEDB_DATASTORE_CONN_URI: "postgres://spicedb:${SPICEDB_POSTGRES_PASSWORD:-spicedb}@postgres:5432/spicedb?sslmode=disable"
|
|
46
|
+
depends_on:
|
|
47
|
+
postgres:
|
|
48
|
+
condition: service_healthy
|
|
49
|
+
restart: "no"
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# SpiceDB — authorization engine
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
spicedb:
|
|
55
|
+
image: authzed/spicedb:latest
|
|
56
|
+
command: serve
|
|
57
|
+
restart: unless-stopped
|
|
58
|
+
ports:
|
|
59
|
+
- "${SPICEDB_GRPC_PORT:-50051}:50051" # gRPC
|
|
60
|
+
- "${SPICEDB_HTTP_PORT:-8080}:8080" # HTTP metrics / healthz
|
|
61
|
+
environment:
|
|
62
|
+
SPICEDB_GRPC_PRESHARED_KEY: ${SPICEDB_PRESHARED_KEY:-dev_token}
|
|
63
|
+
SPICEDB_DATASTORE_ENGINE: postgres
|
|
64
|
+
SPICEDB_DATASTORE_CONN_URI: "postgres://spicedb:${SPICEDB_POSTGRES_PASSWORD:-spicedb}@postgres:5432/spicedb?sslmode=disable"
|
|
65
|
+
SPICEDB_GRPC_NO_TLS: "${SPICEDB_GRPC_NO_TLS:-true}"
|
|
66
|
+
depends_on:
|
|
67
|
+
spicedb-migrate:
|
|
68
|
+
condition: service_completed_successfully
|
|
69
|
+
postgres:
|
|
70
|
+
condition: service_healthy
|
|
71
|
+
healthcheck:
|
|
72
|
+
test: ["CMD", "grpc_health_probe", "-addr=localhost:50051"]
|
|
73
|
+
interval: 10s
|
|
74
|
+
timeout: 5s
|
|
75
|
+
retries: 5
|
|
76
|
+
start_period: 10s
|
|
77
|
+
|
|
78
|
+
volumes:
|
|
79
|
+
graphiti_spicedb_postgres_data:
|