@contextableai/openclaw-memory-graphiti 0.1.1
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 +424 -0
- package/authorization.ts +202 -0
- package/config.ts +111 -0
- package/docker/.env.example +66 -0
- package/docker/docker-compose.yml +151 -0
- package/graphiti.ts +382 -0
- package/index.ts +942 -0
- package/openclaw.plugin.json +91 -0
- package/package.json +51 -0
- package/schema.zed +23 -0
- package/scripts/dev-setup.sh +146 -0
- package/scripts/dev-start.sh +236 -0
- package/scripts/dev-status.sh +57 -0
- package/scripts/dev-stop.sh +47 -0
- package/search.ts +201 -0
- package/spicedb.ts +174 -0
package/config.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
export type GraphitiMemoryConfig = {
|
|
2
|
+
spicedb: {
|
|
3
|
+
endpoint: string;
|
|
4
|
+
token: string;
|
|
5
|
+
insecure: boolean;
|
|
6
|
+
};
|
|
7
|
+
graphiti: {
|
|
8
|
+
endpoint: string;
|
|
9
|
+
defaultGroupId: string;
|
|
10
|
+
};
|
|
11
|
+
subjectType: "agent" | "person";
|
|
12
|
+
subjectId: string;
|
|
13
|
+
autoCapture: boolean;
|
|
14
|
+
autoRecall: boolean;
|
|
15
|
+
customInstructions: string;
|
|
16
|
+
maxCaptureMessages: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const DEFAULT_SPICEDB_ENDPOINT = "localhost:50051";
|
|
20
|
+
const DEFAULT_GRAPHITI_ENDPOINT = "http://localhost:8000";
|
|
21
|
+
const DEFAULT_GROUP_ID = "main";
|
|
22
|
+
const DEFAULT_SUBJECT_TYPE = "agent";
|
|
23
|
+
const DEFAULT_MAX_CAPTURE_MESSAGES = 10;
|
|
24
|
+
|
|
25
|
+
const DEFAULT_CUSTOM_INSTRUCTIONS = `Extract key facts about:
|
|
26
|
+
- Identity: names, roles, titles, contact info
|
|
27
|
+
- Preferences: likes, dislikes, preferred tools/methods
|
|
28
|
+
- Goals: objectives, plans, deadlines
|
|
29
|
+
- Relationships: connections between people, teams, organizations
|
|
30
|
+
- Decisions: choices made, reasoning, outcomes
|
|
31
|
+
- Routines: habits, schedules, recurring patterns
|
|
32
|
+
Do not extract: greetings, filler, meta-commentary about the conversation itself.`;
|
|
33
|
+
|
|
34
|
+
function resolveEnvVars(value: string): string {
|
|
35
|
+
return value.replace(/\$\{([^}]+)\}/g, (_, envVar) => {
|
|
36
|
+
const envValue = process.env[envVar];
|
|
37
|
+
if (!envValue) {
|
|
38
|
+
throw new Error(`Environment variable ${envVar} is not set`);
|
|
39
|
+
}
|
|
40
|
+
return envValue;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function assertAllowedKeys(value: Record<string, unknown>, allowed: string[], label: string) {
|
|
45
|
+
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
46
|
+
if (unknown.length > 0) {
|
|
47
|
+
throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const graphitiMemoryConfigSchema = {
|
|
52
|
+
parse(value: unknown): GraphitiMemoryConfig {
|
|
53
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
54
|
+
throw new Error("memory-graphiti config required");
|
|
55
|
+
}
|
|
56
|
+
const cfg = value as Record<string, unknown>;
|
|
57
|
+
assertAllowedKeys(
|
|
58
|
+
cfg,
|
|
59
|
+
[
|
|
60
|
+
"spicedb", "graphiti", "subjectType", "subjectId",
|
|
61
|
+
"autoCapture", "autoRecall", "customInstructions", "maxCaptureMessages",
|
|
62
|
+
],
|
|
63
|
+
"memory-graphiti config",
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// SpiceDB config
|
|
67
|
+
const spicedb = cfg.spicedb as Record<string, unknown> | undefined;
|
|
68
|
+
if (!spicedb || typeof spicedb.token !== "string") {
|
|
69
|
+
throw new Error("spicedb.token is required");
|
|
70
|
+
}
|
|
71
|
+
assertAllowedKeys(spicedb, ["endpoint", "token", "insecure"], "spicedb config");
|
|
72
|
+
|
|
73
|
+
// Graphiti config
|
|
74
|
+
const graphiti = (cfg.graphiti as Record<string, unknown>) ?? {};
|
|
75
|
+
assertAllowedKeys(graphiti, ["endpoint", "defaultGroupId"], "graphiti config");
|
|
76
|
+
|
|
77
|
+
// Subject
|
|
78
|
+
const subjectType = cfg.subjectType === "person" ? "person" : DEFAULT_SUBJECT_TYPE;
|
|
79
|
+
const subjectId =
|
|
80
|
+
typeof cfg.subjectId === "string" ? resolveEnvVars(cfg.subjectId) : "default";
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
spicedb: {
|
|
84
|
+
endpoint:
|
|
85
|
+
typeof spicedb.endpoint === "string" ? spicedb.endpoint : DEFAULT_SPICEDB_ENDPOINT,
|
|
86
|
+
token: resolveEnvVars(spicedb.token),
|
|
87
|
+
insecure: spicedb.insecure !== false,
|
|
88
|
+
},
|
|
89
|
+
graphiti: {
|
|
90
|
+
endpoint:
|
|
91
|
+
typeof graphiti.endpoint === "string" ? graphiti.endpoint : DEFAULT_GRAPHITI_ENDPOINT,
|
|
92
|
+
defaultGroupId:
|
|
93
|
+
typeof graphiti.defaultGroupId === "string"
|
|
94
|
+
? graphiti.defaultGroupId
|
|
95
|
+
: DEFAULT_GROUP_ID,
|
|
96
|
+
},
|
|
97
|
+
subjectType,
|
|
98
|
+
subjectId,
|
|
99
|
+
autoCapture: cfg.autoCapture !== false,
|
|
100
|
+
autoRecall: cfg.autoRecall !== false,
|
|
101
|
+
customInstructions:
|
|
102
|
+
typeof cfg.customInstructions === "string"
|
|
103
|
+
? cfg.customInstructions
|
|
104
|
+
: DEFAULT_CUSTOM_INSTRUCTIONS,
|
|
105
|
+
maxCaptureMessages:
|
|
106
|
+
typeof cfg.maxCaptureMessages === "number" && cfg.maxCaptureMessages > 0
|
|
107
|
+
? cfg.maxCaptureMessages
|
|
108
|
+
: DEFAULT_MAX_CAPTURE_MESSAGES,
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# ===========================================================================
|
|
2
|
+
# Required
|
|
3
|
+
# ===========================================================================
|
|
4
|
+
|
|
5
|
+
# OpenAI API key — Graphiti uses this for entity extraction and embeddings
|
|
6
|
+
OPENAI_API_KEY=sk-proj-...
|
|
7
|
+
|
|
8
|
+
# ===========================================================================
|
|
9
|
+
# Optional (sensible defaults provided)
|
|
10
|
+
# ===========================================================================
|
|
11
|
+
|
|
12
|
+
# SpiceDB pre-shared authentication key
|
|
13
|
+
SPICEDB_TOKEN=dev_token
|
|
14
|
+
|
|
15
|
+
# PostgreSQL password (SpiceDB persistent datastore)
|
|
16
|
+
SPICEDB_DB_PASSWORD=spicedb_dev
|
|
17
|
+
|
|
18
|
+
# OpenClaw gateway image (default: locally built)
|
|
19
|
+
# OPENCLAW_IMAGE=openclaw:local
|
|
20
|
+
|
|
21
|
+
# OpenClaw gateway credentials (from your existing setup)
|
|
22
|
+
# OPENCLAW_GATEWAY_TOKEN=
|
|
23
|
+
# CLAUDE_AI_SESSION_KEY=
|
|
24
|
+
# CLAUDE_WEB_SESSION_KEY=
|
|
25
|
+
# CLAUDE_WEB_COOKIE=
|
|
26
|
+
|
|
27
|
+
# OpenClaw directory mounts
|
|
28
|
+
# OPENCLAW_CONFIG_DIR=./config
|
|
29
|
+
# OPENCLAW_WORKSPACE_DIR=./workspace
|
|
30
|
+
|
|
31
|
+
# Port overrides (if defaults conflict with other services)
|
|
32
|
+
# OPENCLAW_GATEWAY_PORT=18789
|
|
33
|
+
# OPENCLAW_BRIDGE_PORT=18790
|
|
34
|
+
# FALKORDB_PORT=6379
|
|
35
|
+
# FALKORDB_UI_PORT=3000
|
|
36
|
+
# GRAPHITI_PORT=8000
|
|
37
|
+
# SPICEDB_GRPC_PORT=50051
|
|
38
|
+
# SPICEDB_HTTP_PORT=8443
|
|
39
|
+
# SPICEDB_METRICS_PORT=9090
|
|
40
|
+
# POSTGRES_PORT=5432
|
|
41
|
+
|
|
42
|
+
# ===========================================================================
|
|
43
|
+
# Plugin config reference
|
|
44
|
+
# ===========================================================================
|
|
45
|
+
#
|
|
46
|
+
# When running via docker compose, services reach each other by hostname.
|
|
47
|
+
# Configure the memory-graphiti plugin in OpenClaw with:
|
|
48
|
+
#
|
|
49
|
+
# {
|
|
50
|
+
# "spicedb": {
|
|
51
|
+
# "endpoint": "spicedb:50051",
|
|
52
|
+
# "token": "${SPICEDB_TOKEN}",
|
|
53
|
+
# "insecure": true
|
|
54
|
+
# },
|
|
55
|
+
# "graphiti": {
|
|
56
|
+
# "endpoint": "http://graphiti-mcp:8000",
|
|
57
|
+
# "defaultGroupId": "main"
|
|
58
|
+
# }
|
|
59
|
+
# }
|
|
60
|
+
#
|
|
61
|
+
# Note: "spicedb" and "graphiti-mcp" are the Docker Compose service names.
|
|
62
|
+
# Docker's built-in DNS resolves them to the correct container IPs.
|
|
63
|
+
#
|
|
64
|
+
# For E2E tests from outside Docker, use localhost with the mapped ports:
|
|
65
|
+
# GRAPHITI_ENDPOINT=http://localhost:8000
|
|
66
|
+
# SPICEDB_ENDPOINT=localhost:50051
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# Full-stack: OpenClaw gateway + memory-graphiti infrastructure
|
|
2
|
+
#
|
|
3
|
+
# All services share the same Docker network, so the gateway plugin
|
|
4
|
+
# reaches Graphiti and SpiceDB by service hostname — no localhost hacks.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# cp .env.example .env # Fill in OPENAI_API_KEY and other vars
|
|
8
|
+
# docker compose up -d # Start everything
|
|
9
|
+
# docker compose logs -f # Watch startup
|
|
10
|
+
#
|
|
11
|
+
# The gateway will be available at http://localhost:18789
|
|
12
|
+
# FalkorDB web UI at http://localhost:3000
|
|
13
|
+
#
|
|
14
|
+
# To run without the gateway (infra only, for dev/testing):
|
|
15
|
+
# docker compose up -d falkordb graphiti-mcp spicedb
|
|
16
|
+
|
|
17
|
+
services:
|
|
18
|
+
# --------------------------------------------------------------------------
|
|
19
|
+
# OpenClaw Gateway
|
|
20
|
+
# --------------------------------------------------------------------------
|
|
21
|
+
openclaw-gateway:
|
|
22
|
+
image: ${OPENCLAW_IMAGE:-openclaw:local}
|
|
23
|
+
environment:
|
|
24
|
+
HOME: /home/node
|
|
25
|
+
TERM: xterm-256color
|
|
26
|
+
OPENCLAW_GATEWAY_TOKEN: ${OPENCLAW_GATEWAY_TOKEN:-}
|
|
27
|
+
CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY:-}
|
|
28
|
+
CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY:-}
|
|
29
|
+
CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE:-}
|
|
30
|
+
# Memory-graphiti plugin will read these from the plugin config,
|
|
31
|
+
# but we also pass them as env vars for ${...} interpolation in config
|
|
32
|
+
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
|
33
|
+
SPICEDB_TOKEN: ${SPICEDB_TOKEN:-dev_token}
|
|
34
|
+
volumes:
|
|
35
|
+
- ${OPENCLAW_CONFIG_DIR:-./config}:/home/node/.openclaw
|
|
36
|
+
- ${OPENCLAW_WORKSPACE_DIR:-./workspace}:/home/node/.openclaw/workspace
|
|
37
|
+
ports:
|
|
38
|
+
- "${OPENCLAW_GATEWAY_PORT:-18789}:18789"
|
|
39
|
+
- "${OPENCLAW_BRIDGE_PORT:-18790}:18790"
|
|
40
|
+
init: true
|
|
41
|
+
restart: unless-stopped
|
|
42
|
+
command:
|
|
43
|
+
[
|
|
44
|
+
"node",
|
|
45
|
+
"dist/index.js",
|
|
46
|
+
"gateway",
|
|
47
|
+
"--bind",
|
|
48
|
+
"${OPENCLAW_GATEWAY_BIND:-lan}",
|
|
49
|
+
"--port",
|
|
50
|
+
"18789",
|
|
51
|
+
]
|
|
52
|
+
depends_on:
|
|
53
|
+
graphiti-mcp:
|
|
54
|
+
condition: service_healthy
|
|
55
|
+
spicedb:
|
|
56
|
+
condition: service_healthy
|
|
57
|
+
|
|
58
|
+
# --------------------------------------------------------------------------
|
|
59
|
+
# FalkorDB — graph database for Graphiti
|
|
60
|
+
# --------------------------------------------------------------------------
|
|
61
|
+
falkordb:
|
|
62
|
+
image: falkordb/falkordb:latest
|
|
63
|
+
ports:
|
|
64
|
+
- "${FALKORDB_PORT:-6379}:6379" # FalkorDB/Redis protocol
|
|
65
|
+
- "${FALKORDB_UI_PORT:-3000}:3000" # FalkorDB web UI
|
|
66
|
+
volumes:
|
|
67
|
+
- falkordb-data:/data
|
|
68
|
+
healthcheck:
|
|
69
|
+
test: ["CMD", "redis-cli", "ping"]
|
|
70
|
+
interval: 5s
|
|
71
|
+
timeout: 3s
|
|
72
|
+
retries: 5
|
|
73
|
+
|
|
74
|
+
# --------------------------------------------------------------------------
|
|
75
|
+
# Graphiti MCP Server — knowledge graph API over HTTP
|
|
76
|
+
# --------------------------------------------------------------------------
|
|
77
|
+
graphiti-mcp:
|
|
78
|
+
image: ghcr.io/getzep/graphiti-mcp:latest
|
|
79
|
+
ports:
|
|
80
|
+
- "${GRAPHITI_PORT:-8000}:8000" # MCP HTTP endpoint
|
|
81
|
+
environment:
|
|
82
|
+
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
|
83
|
+
- FALKORDB_URI=redis://falkordb:6379
|
|
84
|
+
depends_on:
|
|
85
|
+
falkordb:
|
|
86
|
+
condition: service_healthy
|
|
87
|
+
healthcheck:
|
|
88
|
+
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
|
89
|
+
interval: 10s
|
|
90
|
+
timeout: 5s
|
|
91
|
+
retries: 5
|
|
92
|
+
|
|
93
|
+
# --------------------------------------------------------------------------
|
|
94
|
+
# PostgreSQL — persistent datastore for SpiceDB
|
|
95
|
+
# --------------------------------------------------------------------------
|
|
96
|
+
postgres:
|
|
97
|
+
image: postgres:16-alpine
|
|
98
|
+
environment:
|
|
99
|
+
POSTGRES_USER: spicedb
|
|
100
|
+
POSTGRES_PASSWORD: ${SPICEDB_DB_PASSWORD:-spicedb_dev}
|
|
101
|
+
POSTGRES_DB: spicedb
|
|
102
|
+
ports:
|
|
103
|
+
- "${POSTGRES_PORT:-5432}:5432"
|
|
104
|
+
volumes:
|
|
105
|
+
- postgres-data:/var/lib/postgresql/data
|
|
106
|
+
healthcheck:
|
|
107
|
+
test: ["CMD-SHELL", "pg_isready -U spicedb"]
|
|
108
|
+
interval: 5s
|
|
109
|
+
timeout: 3s
|
|
110
|
+
retries: 5
|
|
111
|
+
|
|
112
|
+
# --------------------------------------------------------------------------
|
|
113
|
+
# SpiceDB — authorization engine (persistent via PostgreSQL)
|
|
114
|
+
# --------------------------------------------------------------------------
|
|
115
|
+
spicedb-migrate:
|
|
116
|
+
image: authzed/spicedb:latest
|
|
117
|
+
command:
|
|
118
|
+
- migrate
|
|
119
|
+
- head
|
|
120
|
+
- --datastore-engine=postgres
|
|
121
|
+
- --datastore-conn-uri=postgres://spicedb:${SPICEDB_DB_PASSWORD:-spicedb_dev}@postgres:5432/spicedb?sslmode=disable
|
|
122
|
+
depends_on:
|
|
123
|
+
postgres:
|
|
124
|
+
condition: service_healthy
|
|
125
|
+
|
|
126
|
+
spicedb:
|
|
127
|
+
image: authzed/spicedb:latest
|
|
128
|
+
command:
|
|
129
|
+
- serve
|
|
130
|
+
- --grpc-preshared-key=${SPICEDB_TOKEN:-dev_token}
|
|
131
|
+
- --datastore-engine=postgres
|
|
132
|
+
- --datastore-conn-uri=postgres://spicedb:${SPICEDB_DB_PASSWORD:-spicedb_dev}@postgres:5432/spicedb?sslmode=disable
|
|
133
|
+
- --http-enabled=true
|
|
134
|
+
ports:
|
|
135
|
+
- "${SPICEDB_GRPC_PORT:-50051}:50051" # gRPC API
|
|
136
|
+
- "${SPICEDB_HTTP_PORT:-8443}:8443" # HTTP gateway
|
|
137
|
+
- "${SPICEDB_METRICS_PORT:-9090}:9090" # Metrics/health
|
|
138
|
+
depends_on:
|
|
139
|
+
postgres:
|
|
140
|
+
condition: service_healthy
|
|
141
|
+
spicedb-migrate:
|
|
142
|
+
condition: service_completed_successfully
|
|
143
|
+
healthcheck:
|
|
144
|
+
test: ["CMD", "grpc_health_probe", "-addr=localhost:50051"]
|
|
145
|
+
interval: 5s
|
|
146
|
+
timeout: 3s
|
|
147
|
+
retries: 5
|
|
148
|
+
|
|
149
|
+
volumes:
|
|
150
|
+
falkordb-data:
|
|
151
|
+
postgres-data:
|
package/graphiti.ts
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graphiti MCP Server HTTP Client
|
|
3
|
+
*
|
|
4
|
+
* Communicates with the Graphiti MCP server via the MCP Streamable HTTP
|
|
5
|
+
* transport (JSON-RPC 2.0 over SSE). Handles session initialization,
|
|
6
|
+
* session ID tracking, and SSE response parsing.
|
|
7
|
+
*
|
|
8
|
+
* Wraps core tools: add_memory, search_nodes, search_memory_facts,
|
|
9
|
+
* get_episodes, delete_episode, get_status.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
|
|
14
|
+
// ============================================================================
|
|
15
|
+
// Types
|
|
16
|
+
// ============================================================================
|
|
17
|
+
|
|
18
|
+
export type GraphitiEpisode = {
|
|
19
|
+
uuid: string;
|
|
20
|
+
name: string;
|
|
21
|
+
content: string;
|
|
22
|
+
source_description: string;
|
|
23
|
+
group_id: string;
|
|
24
|
+
created_at: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type GraphitiNode = {
|
|
28
|
+
uuid: string;
|
|
29
|
+
name: string;
|
|
30
|
+
summary: string | null;
|
|
31
|
+
group_id: string;
|
|
32
|
+
labels: string[];
|
|
33
|
+
created_at: string | null;
|
|
34
|
+
attributes: Record<string, unknown>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type GraphitiFact = {
|
|
38
|
+
uuid: string;
|
|
39
|
+
fact: string;
|
|
40
|
+
name?: string;
|
|
41
|
+
source_node_uuid?: string;
|
|
42
|
+
target_node_uuid?: string;
|
|
43
|
+
source_node_name?: string;
|
|
44
|
+
target_node_name?: string;
|
|
45
|
+
group_id: string;
|
|
46
|
+
created_at: string;
|
|
47
|
+
[key: string]: unknown;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type AddEpisodeResult = {
|
|
51
|
+
episode_uuid: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
type JsonRpcRequest = {
|
|
55
|
+
jsonrpc: "2.0";
|
|
56
|
+
id: number;
|
|
57
|
+
method: string;
|
|
58
|
+
params?: Record<string, unknown>;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
type JsonRpcResponse = {
|
|
62
|
+
jsonrpc: "2.0";
|
|
63
|
+
id: number;
|
|
64
|
+
result?: unknown;
|
|
65
|
+
error?: { code: number; message: string; data?: unknown };
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// ============================================================================
|
|
69
|
+
// Client
|
|
70
|
+
// ============================================================================
|
|
71
|
+
|
|
72
|
+
export class GraphitiClient {
|
|
73
|
+
private nextId = 1;
|
|
74
|
+
private sessionId: string | null = null;
|
|
75
|
+
private initPromise: Promise<void> | null = null;
|
|
76
|
+
|
|
77
|
+
constructor(private readonly endpoint: string) {}
|
|
78
|
+
|
|
79
|
+
// --------------------------------------------------------------------------
|
|
80
|
+
// MCP Session Lifecycle
|
|
81
|
+
// --------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
private async ensureInitialized(): Promise<void> {
|
|
84
|
+
if (this.sessionId) return;
|
|
85
|
+
if (!this.initPromise) {
|
|
86
|
+
this.initPromise = this.doInitialize();
|
|
87
|
+
}
|
|
88
|
+
return this.initPromise;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private async doInitialize(): Promise<void> {
|
|
92
|
+
const request: JsonRpcRequest = {
|
|
93
|
+
jsonrpc: "2.0",
|
|
94
|
+
id: this.nextId++,
|
|
95
|
+
method: "initialize",
|
|
96
|
+
params: {
|
|
97
|
+
protocolVersion: "2025-11-25",
|
|
98
|
+
capabilities: {},
|
|
99
|
+
clientInfo: { name: "openclaw-memory-graphiti", version: "1.0.0" },
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const response = await fetch(`${this.endpoint}/mcp`, {
|
|
104
|
+
method: "POST",
|
|
105
|
+
headers: {
|
|
106
|
+
"Content-Type": "application/json",
|
|
107
|
+
Accept: "application/json, text/event-stream",
|
|
108
|
+
},
|
|
109
|
+
body: JSON.stringify(request),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (!response.ok) {
|
|
113
|
+
this.initPromise = null;
|
|
114
|
+
throw new Error(`Graphiti MCP init failed: ${response.status} ${response.statusText}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Capture session ID from response header
|
|
118
|
+
this.sessionId = response.headers.get("mcp-session-id");
|
|
119
|
+
|
|
120
|
+
// Consume the SSE response body
|
|
121
|
+
await this.parseSseResponse(response);
|
|
122
|
+
|
|
123
|
+
// Send notifications/initialized (required by MCP protocol)
|
|
124
|
+
const headers: Record<string, string> = {
|
|
125
|
+
"Content-Type": "application/json",
|
|
126
|
+
Accept: "application/json, text/event-stream",
|
|
127
|
+
};
|
|
128
|
+
if (this.sessionId) {
|
|
129
|
+
headers["mcp-session-id"] = this.sessionId;
|
|
130
|
+
}
|
|
131
|
+
await fetch(`${this.endpoint}/mcp`, {
|
|
132
|
+
method: "POST",
|
|
133
|
+
headers,
|
|
134
|
+
body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async close(): Promise<void> {
|
|
139
|
+
if (this.sessionId) {
|
|
140
|
+
try {
|
|
141
|
+
await fetch(`${this.endpoint}/mcp`, {
|
|
142
|
+
method: "DELETE",
|
|
143
|
+
headers: { "mcp-session-id": this.sessionId },
|
|
144
|
+
});
|
|
145
|
+
} catch {
|
|
146
|
+
// Ignore cleanup errors
|
|
147
|
+
}
|
|
148
|
+
this.sessionId = null;
|
|
149
|
+
this.initPromise = null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// --------------------------------------------------------------------------
|
|
154
|
+
// JSON-RPC / SSE Transport
|
|
155
|
+
// --------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
private async callTool(name: string, args: Record<string, unknown> = {}): Promise<unknown> {
|
|
158
|
+
await this.ensureInitialized();
|
|
159
|
+
|
|
160
|
+
const request: JsonRpcRequest = {
|
|
161
|
+
jsonrpc: "2.0",
|
|
162
|
+
id: this.nextId++,
|
|
163
|
+
method: "tools/call",
|
|
164
|
+
params: { name, arguments: args },
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const headers: Record<string, string> = {
|
|
168
|
+
"Content-Type": "application/json",
|
|
169
|
+
Accept: "application/json, text/event-stream",
|
|
170
|
+
};
|
|
171
|
+
if (this.sessionId) {
|
|
172
|
+
headers["mcp-session-id"] = this.sessionId;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const response = await fetch(`${this.endpoint}/mcp`, {
|
|
176
|
+
method: "POST",
|
|
177
|
+
headers,
|
|
178
|
+
body: JSON.stringify(request),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
if (!response.ok) {
|
|
182
|
+
throw new Error(`Graphiti MCP server error: ${response.status} ${response.statusText}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const json = await this.parseResponse(response);
|
|
186
|
+
|
|
187
|
+
if (json.error) {
|
|
188
|
+
throw new Error(`Graphiti tool ${name} failed: ${json.error.message}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return json.result;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private async parseResponse(response: Response): Promise<JsonRpcResponse> {
|
|
195
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
196
|
+
if (contentType.includes("text/event-stream")) {
|
|
197
|
+
return this.parseSseResponse(response);
|
|
198
|
+
}
|
|
199
|
+
return (await response.json()) as JsonRpcResponse;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private async parseSseResponse(response: Response): Promise<JsonRpcResponse> {
|
|
203
|
+
const text = await response.text();
|
|
204
|
+
for (const line of text.split("\n")) {
|
|
205
|
+
if (line.startsWith("data: ")) {
|
|
206
|
+
const data = line.slice(6).trim();
|
|
207
|
+
if (data) {
|
|
208
|
+
return JSON.parse(data) as JsonRpcResponse;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
throw new Error("No JSON-RPC message found in SSE response");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// --------------------------------------------------------------------------
|
|
216
|
+
// Health
|
|
217
|
+
// --------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
async healthCheck(): Promise<boolean> {
|
|
220
|
+
try {
|
|
221
|
+
const response = await fetch(`${this.endpoint}/health`);
|
|
222
|
+
return response.ok;
|
|
223
|
+
} catch {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async getStatus(): Promise<unknown> {
|
|
229
|
+
return this.callTool("get_status");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// --------------------------------------------------------------------------
|
|
233
|
+
// Episodes
|
|
234
|
+
// --------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
async addEpisode(params: {
|
|
237
|
+
name: string;
|
|
238
|
+
episode_body: string;
|
|
239
|
+
source_description?: string;
|
|
240
|
+
group_id?: string;
|
|
241
|
+
source?: string;
|
|
242
|
+
/**
|
|
243
|
+
* Custom extraction instructions for Graphiti's LLM entity extraction.
|
|
244
|
+
* The MCP server doesn't expose this parameter, so we prepend the
|
|
245
|
+
* instructions to episode_body with clear delimiters. Graphiti's
|
|
246
|
+
* extraction prompts include the full episode content, so the LLM
|
|
247
|
+
* will see these instructions inline alongside the actual content.
|
|
248
|
+
*/
|
|
249
|
+
custom_extraction_instructions?: string;
|
|
250
|
+
/** @deprecated The MCP server's uuid param is for re-processing existing episodes */
|
|
251
|
+
uuid?: string;
|
|
252
|
+
/** @deprecated No longer supported by the Graphiti MCP server */
|
|
253
|
+
reference_time?: string;
|
|
254
|
+
}): Promise<AddEpisodeResult> {
|
|
255
|
+
// Note: The Graphiti MCP server's uuid parameter is for re-processing
|
|
256
|
+
// existing episodes, NOT for setting the UUID of new ones. We generate
|
|
257
|
+
// a client-side tracking UUID instead (it won't match the server-side UUID).
|
|
258
|
+
const trackingUuid = randomUUID();
|
|
259
|
+
|
|
260
|
+
// Prepend custom extraction instructions to episode_body since the MCP
|
|
261
|
+
// server doesn't support custom_extraction_instructions as a parameter.
|
|
262
|
+
// The extraction LLM sees the full episode content, so inline instructions work.
|
|
263
|
+
let effectiveBody = params.episode_body;
|
|
264
|
+
if (params.custom_extraction_instructions) {
|
|
265
|
+
effectiveBody =
|
|
266
|
+
`[Extraction Instructions]\n${params.custom_extraction_instructions}\n[End Instructions]\n\n${params.episode_body}`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const args: Record<string, unknown> = {
|
|
270
|
+
name: params.name,
|
|
271
|
+
episode_body: effectiveBody,
|
|
272
|
+
};
|
|
273
|
+
if (params.group_id) {
|
|
274
|
+
args.group_id = params.group_id;
|
|
275
|
+
}
|
|
276
|
+
if (params.source) {
|
|
277
|
+
args.source = params.source;
|
|
278
|
+
}
|
|
279
|
+
if (params.source_description) {
|
|
280
|
+
args.source_description = params.source_description;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
await this.callTool("add_memory", args);
|
|
284
|
+
return { episode_uuid: trackingUuid };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async getEpisodes(groupId: string, lastN: number): Promise<GraphitiEpisode[]> {
|
|
288
|
+
const result = await this.callTool("get_episodes", {
|
|
289
|
+
group_ids: [groupId],
|
|
290
|
+
max_episodes: lastN,
|
|
291
|
+
});
|
|
292
|
+
return parseToolResult<GraphitiEpisode[]>(result, "episodes");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async deleteEpisode(episodeUuid: string): Promise<void> {
|
|
296
|
+
await this.callTool("delete_episode", { uuid: episodeUuid });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// --------------------------------------------------------------------------
|
|
300
|
+
// Search
|
|
301
|
+
// --------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
async searchNodes(params: {
|
|
304
|
+
query: string;
|
|
305
|
+
group_id?: string;
|
|
306
|
+
group_ids?: string[];
|
|
307
|
+
limit?: number;
|
|
308
|
+
}): Promise<GraphitiNode[]> {
|
|
309
|
+
const args: Record<string, unknown> = {
|
|
310
|
+
query: params.query,
|
|
311
|
+
};
|
|
312
|
+
const groupIds = params.group_ids ?? (params.group_id ? [params.group_id] : undefined);
|
|
313
|
+
if (groupIds) {
|
|
314
|
+
args.group_ids = groupIds;
|
|
315
|
+
}
|
|
316
|
+
if (params.limit !== undefined) {
|
|
317
|
+
args.max_nodes = params.limit;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const result = await this.callTool("search_nodes", args);
|
|
321
|
+
return parseToolResult<GraphitiNode[]>(result, "nodes");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async searchFacts(params: {
|
|
325
|
+
query: string;
|
|
326
|
+
group_id?: string;
|
|
327
|
+
group_ids?: string[];
|
|
328
|
+
limit?: number;
|
|
329
|
+
/** @deprecated No longer supported by the Graphiti MCP server */
|
|
330
|
+
created_after?: string;
|
|
331
|
+
}): Promise<GraphitiFact[]> {
|
|
332
|
+
const args: Record<string, unknown> = {
|
|
333
|
+
query: params.query,
|
|
334
|
+
};
|
|
335
|
+
const groupIds = params.group_ids ?? (params.group_id ? [params.group_id] : undefined);
|
|
336
|
+
if (groupIds) {
|
|
337
|
+
args.group_ids = groupIds;
|
|
338
|
+
}
|
|
339
|
+
if (params.limit !== undefined) {
|
|
340
|
+
args.max_facts = params.limit;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const result = await this.callTool("search_memory_facts", args);
|
|
344
|
+
return parseToolResult<GraphitiFact[]>(result, "facts");
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ============================================================================
|
|
349
|
+
// Helpers
|
|
350
|
+
// ============================================================================
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Extract a typed result from an MCP tool response.
|
|
354
|
+
* The response is typically wrapped in content blocks:
|
|
355
|
+
* { content: [{ type: "text", text: "{ \"nodes\": [...] }" }] }
|
|
356
|
+
* This function unwraps the content block, parses the JSON, and extracts
|
|
357
|
+
* the named field (e.g. "nodes", "facts", "episodes").
|
|
358
|
+
*/
|
|
359
|
+
function parseToolResult<T>(result: unknown, key: string): T {
|
|
360
|
+
const parsed = parseJsonResult<Record<string, unknown>>(result);
|
|
361
|
+
if (parsed && typeof parsed === "object" && key in parsed) {
|
|
362
|
+
return parsed[key] as T;
|
|
363
|
+
}
|
|
364
|
+
return [] as unknown as T;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function parseJsonResult<T>(result: unknown): T {
|
|
368
|
+
if (typeof result === "string") {
|
|
369
|
+
return JSON.parse(result) as T;
|
|
370
|
+
}
|
|
371
|
+
// MCP tool results are typically wrapped in content blocks
|
|
372
|
+
if (result && typeof result === "object" && "content" in result) {
|
|
373
|
+
const content = (result as Record<string, unknown>).content;
|
|
374
|
+
if (Array.isArray(content) && content.length > 0) {
|
|
375
|
+
const first = content[0] as Record<string, unknown>;
|
|
376
|
+
if (first.type === "text" && typeof first.text === "string") {
|
|
377
|
+
return JSON.parse(first.text) as T;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return result as T;
|
|
382
|
+
}
|