@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/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
+ }