@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 ADDED
@@ -0,0 +1,424 @@
1
+ # @openclaw/memory-graphiti
2
+
3
+ Two-layer memory plugin for OpenClaw: **SpiceDB** for authorization, **Graphiti** for knowledge graph storage.
4
+
5
+ Agents remember conversations as structured entities and facts in a knowledge graph. SpiceDB enforces who can read and write which memories — authorization is at the data layer, not in prompts.
6
+
7
+ ## Architecture
8
+
9
+ ```
10
+ ┌──────────────────────────────────────────────────┐
11
+ │ OpenClaw Agent │
12
+ │ │
13
+ │ memory_recall ──► SpiceDB ──► Graphiti Search │
14
+ │ memory_store ──► SpiceDB ──► Graphiti Write │
15
+ │ memory_forget ──► SpiceDB ──► Graphiti Delete │
16
+ │ auto-recall ──► SpiceDB ──► Graphiti Search │
17
+ │ auto-capture ──► SpiceDB ──► Graphiti Write │
18
+ └──────────────────────────────────────────────────┘
19
+ │ │
20
+ ┌────▼────┐ ┌─────▼─────┐
21
+ │ SpiceDB │ │ Graphiti │
22
+ │ (authz) │ │ MCP Server │
23
+ └─────────┘ └─────┬─────┘
24
+
25
+ ┌─────▼─────┐
26
+ │ FalkorDB │
27
+ │ (graph) │
28
+ └───────────┘
29
+ ```
30
+
31
+ **SpiceDB** determines which `group_id`s a subject (agent or person) can access, then **Graphiti** searches or stores memories scoped to those groups.
32
+
33
+ ## Quick Start
34
+
35
+ ### Prerequisites
36
+
37
+ - [Docker](https://docs.docker.com/get-docker/) and Docker Compose
38
+ - An [OpenAI API key](https://platform.openai.com/api-keys) (Graphiti uses OpenAI for entity extraction and embeddings)
39
+
40
+ ### 1. Start Infrastructure
41
+
42
+ ```bash
43
+ cd docker
44
+ cp .env.example .env
45
+ # Edit .env — set OPENAI_API_KEY at minimum
46
+ docker compose up -d falkordb graphiti-mcp spicedb
47
+ ```
48
+
49
+ This starts:
50
+ - **FalkorDB** on port 6379 (graph database, web UI on port 3000)
51
+ - **Graphiti MCP Server** on port 8000 (knowledge graph API)
52
+ - **SpiceDB** on port 50051 (authorization engine)
53
+
54
+ ### 2. Configure the Plugin
55
+
56
+ Add to your OpenClaw plugin configuration:
57
+
58
+ ```json
59
+ {
60
+ "spicedb": {
61
+ "endpoint": "localhost:50051",
62
+ "token": "dev_token",
63
+ "insecure": true
64
+ },
65
+ "graphiti": {
66
+ "endpoint": "http://localhost:8000",
67
+ "defaultGroupId": "main"
68
+ },
69
+ "subjectId": "my-agent"
70
+ }
71
+ ```
72
+
73
+ ### 3. Initialize SpiceDB Schema
74
+
75
+ ```bash
76
+ openclaw graphiti-mem schema-write
77
+ ```
78
+
79
+ ### 4. Add Group Membership
80
+
81
+ ```bash
82
+ # Add the agent to a group
83
+ openclaw graphiti-mem add-member main my-agent --type agent
84
+
85
+ # Add people to groups
86
+ openclaw graphiti-mem add-member family mom --type person
87
+ openclaw graphiti-mem add-member family dad --type person
88
+ ```
89
+
90
+ ## Tools
91
+
92
+ The plugin registers four tools available to the agent:
93
+
94
+ ### memory_recall
95
+
96
+ Search memories using the knowledge graph. Returns entities and facts the current subject is authorized to see.
97
+
98
+ | Parameter | Type | Default | Description |
99
+ |-----------|------|---------|-------------|
100
+ | `query` | string | *required* | Search query |
101
+ | `limit` | number | 10 | Max results |
102
+ | `scope` | string | `"all"` | `"session"`, `"long-term"`, or `"all"` |
103
+
104
+ Searches both **nodes** (entities) and **facts** (relationships) across all authorized groups in parallel, then deduplicates and ranks by recency.
105
+
106
+ ### memory_store
107
+
108
+ Save information to the knowledge graph. Graphiti automatically extracts entities and facts from the content.
109
+
110
+ | Parameter | Type | Default | Description |
111
+ |-----------|------|---------|-------------|
112
+ | `content` | string | *required* | Information to remember |
113
+ | `source_description` | string | `"conversation"` | Context about the source |
114
+ | `involves` | string[] | `[]` | Person/agent IDs involved |
115
+ | `group_id` | string | configured default | Target group for this memory |
116
+ | `longTerm` | boolean | `true` | `false` stores to the current session group |
117
+
118
+ Write authorization is enforced before storing:
119
+ - **Own session groups** auto-create membership (the agent inherits exclusive access)
120
+ - **All other groups** require `contribute` permission in SpiceDB
121
+
122
+ ### memory_forget
123
+
124
+ Delete a memory episode. Requires `delete` permission (only the subject who stored the memory can delete it).
125
+
126
+ | Parameter | Type | Description |
127
+ |-----------|------|-------------|
128
+ | `episode_id` | string | Episode UUID to delete |
129
+
130
+ ### memory_status
131
+
132
+ Check the health of Graphiti and SpiceDB services. No parameters.
133
+
134
+ ## Automatic Behaviors
135
+
136
+ ### Auto-Recall
137
+
138
+ When enabled (default: `true`), the plugin searches relevant memories before each agent turn and injects them into the conversation context as `<relevant-memories>` blocks.
139
+
140
+ - Searches up to 5 long-term memories and 3 session memories per turn
141
+ - Deduplicates session results against long-term results
142
+ - Only triggers when the user prompt is at least 5 characters
143
+
144
+ ### Auto-Capture
145
+
146
+ When enabled (default: `true`), the plugin captures the last N messages from each completed agent turn and stores them as a batch episode in Graphiti.
147
+
148
+ - Captures up to `maxCaptureMessages` messages (default: 10)
149
+ - Stores to the current session group by default
150
+ - Skips messages shorter than 5 characters and injected context blocks
151
+ - Uses custom extraction instructions for entity/fact extraction
152
+
153
+ ## Authorization Model
154
+
155
+ The SpiceDB schema defines four object types:
156
+
157
+ ```
158
+ person {}
159
+
160
+ agent {
161
+ relation owner: person
162
+ permission act_as = owner
163
+ }
164
+
165
+ group {
166
+ relation member: person | agent
167
+ permission access = member
168
+ permission contribute = member
169
+ }
170
+
171
+ memory_fragment {
172
+ relation source_group: group
173
+ relation involves: person | agent
174
+ relation shared_by: person | agent
175
+
176
+ permission view = involves + shared_by + source_group->access
177
+ permission delete = shared_by
178
+ }
179
+ ```
180
+
181
+ ### Groups
182
+
183
+ Groups organize memories and control access. A subject must be a **member** of a group to read (`access`) or write (`contribute`) to it.
184
+
185
+ Membership is managed via the CLI (`graphiti-mem add-member`) or programmatically via `ensureGroupMembership()`.
186
+
187
+ ### Memory Fragments
188
+
189
+ Each stored memory creates a `memory_fragment` with three relationships:
190
+ - **source_group** — which group the memory belongs to
191
+ - **shared_by** — who stored the memory (can delete it)
192
+ - **involves** — people/agents mentioned in the memory (can view it)
193
+
194
+ View permission is granted to anyone who is directly involved, shared the memory, or has access to the source group. Delete permission is restricted to the subject who shared (stored) the memory.
195
+
196
+ ### Session Groups
197
+
198
+ Session groups (`session-<id>`) provide per-conversation memory isolation:
199
+ - The agent that creates a session automatically gets exclusive membership
200
+ - Other agents cannot read or write to foreign session groups without explicit membership
201
+ - Session memories are searchable within the session scope and are deduplicated against long-term memories
202
+
203
+ ## Configuration Reference
204
+
205
+ | Key | Type | Default | Description |
206
+ |-----|------|---------|-------------|
207
+ | `spicedb.endpoint` | string | `localhost:50051` | SpiceDB gRPC endpoint |
208
+ | `spicedb.token` | string | *required* | SpiceDB pre-shared key (supports `${ENV_VAR}`) |
209
+ | `spicedb.insecure` | boolean | `true` | Allow insecure gRPC (for localhost dev) |
210
+ | `graphiti.endpoint` | string | `http://localhost:8000` | Graphiti MCP server URL |
211
+ | `graphiti.defaultGroupId` | string | `main` | Default group for memory storage |
212
+ | `subjectType` | string | `agent` | SpiceDB subject type (`agent` or `person`) |
213
+ | `subjectId` | string | `default` | SpiceDB subject ID (supports `${ENV_VAR}`) |
214
+ | `autoCapture` | boolean | `true` | Auto-capture conversations |
215
+ | `autoRecall` | boolean | `true` | Auto-inject relevant memories |
216
+ | `customInstructions` | string | *(see below)* | Custom extraction instructions for Graphiti |
217
+ | `maxCaptureMessages` | integer | `10` | Max messages per auto-capture batch (1-50) |
218
+
219
+ ### Default Custom Instructions
220
+
221
+ When not overridden, the plugin uses these extraction instructions:
222
+
223
+ ```
224
+ Extract key facts about:
225
+ - Identity: names, roles, titles, contact info
226
+ - Preferences: likes, dislikes, preferred tools/methods
227
+ - Goals: objectives, plans, deadlines
228
+ - Relationships: connections between people, teams, organizations
229
+ - Decisions: choices made, reasoning, outcomes
230
+ - Routines: habits, schedules, recurring patterns
231
+ Do not extract: greetings, filler, meta-commentary about the conversation itself.
232
+ ```
233
+
234
+ ### Environment Variable Interpolation
235
+
236
+ String values in the config support `${ENV_VAR}` syntax:
237
+
238
+ ```json
239
+ {
240
+ "spicedb": {
241
+ "token": "${SPICEDB_TOKEN}"
242
+ },
243
+ "subjectId": "${OPENCLAW_AGENT_ID}"
244
+ }
245
+ ```
246
+
247
+ ## CLI Commands
248
+
249
+ All commands are under `graphiti-mem`:
250
+
251
+ | Command | Description |
252
+ |---------|-------------|
253
+ | `graphiti-mem search <query>` | Search memories with authorization. Options: `--limit`, `--scope` |
254
+ | `graphiti-mem episodes` | List recent episodes. Options: `--last`, `--group` |
255
+ | `graphiti-mem status` | Check SpiceDB + Graphiti connectivity |
256
+ | `graphiti-mem schema-write` | Write/update the SpiceDB authorization schema |
257
+ | `graphiti-mem groups` | List authorized groups for the current subject |
258
+ | `graphiti-mem add-member <group-id> <subject-id>` | Add a subject to a group. Options: `--type` |
259
+ | `graphiti-mem import` | Import workspace markdown files into Graphiti. Options: `--workspace`, `--include-sessions`, `--session-dir`, `--group`, `--dry-run` |
260
+
261
+ ## Docker Compose
262
+
263
+ The `docker/` directory contains a full-stack Docker Compose configuration:
264
+
265
+ | Service | Port | Description |
266
+ |---------|------|-------------|
267
+ | `falkordb` | 6379, 3000 | Graph database (Redis protocol) + web UI |
268
+ | `graphiti-mcp` | 8000 | Graphiti MCP server (HTTP/SSE) |
269
+ | `postgres` | 5432 | Persistent datastore for SpiceDB |
270
+ | `spicedb-migrate` | — | One-shot: runs SpiceDB DB migrations |
271
+ | `spicedb` | 50051, 8443, 9090 | Authorization engine (gRPC, HTTP, metrics) |
272
+ | `openclaw-gateway` | 18789, 18790 | OpenClaw gateway (optional) |
273
+
274
+ ### Infrastructure Only
275
+
276
+ ```bash
277
+ docker compose up -d falkordb graphiti-mcp postgres spicedb-migrate spicedb
278
+ ```
279
+
280
+ ### Full Stack (Gateway + Infrastructure)
281
+
282
+ ```bash
283
+ docker compose up -d
284
+ ```
285
+
286
+ When running inside Docker Compose, use service hostnames in the plugin config:
287
+
288
+ ```json
289
+ {
290
+ "spicedb": {
291
+ "endpoint": "spicedb:50051",
292
+ "token": "${SPICEDB_TOKEN}"
293
+ },
294
+ "graphiti": {
295
+ "endpoint": "http://graphiti-mcp:8000"
296
+ }
297
+ }
298
+ ```
299
+
300
+ ## OpenClaw Integration
301
+
302
+ ### Selecting the Memory Slot
303
+
304
+ OpenClaw has an exclusive `memory` slot — only one memory plugin is active at a time. To use memory-graphiti, set the slot in your OpenClaw config (`~/.openclaw/openclaw.json`):
305
+
306
+ ```json
307
+ {
308
+ "plugins": {
309
+ "slots": {
310
+ "memory": "memory-graphiti"
311
+ },
312
+ "entries": {
313
+ "memory-graphiti": {
314
+ "enabled": true,
315
+ "config": {
316
+ "spicedb": {
317
+ "endpoint": "localhost:50051",
318
+ "token": "dev_token",
319
+ "insecure": true
320
+ },
321
+ "graphiti": {
322
+ "endpoint": "http://localhost:8000",
323
+ "defaultGroupId": "main"
324
+ },
325
+ "subjectType": "agent",
326
+ "subjectId": "my-agent",
327
+ "autoCapture": true,
328
+ "autoRecall": true
329
+ }
330
+ }
331
+ }
332
+ }
333
+ }
334
+ ```
335
+
336
+ The plugin must be discoverable — either symlinked into `extensions/memory-graphiti` in the OpenClaw installation, or loaded via `plugins.load.paths`.
337
+
338
+ ### Initialization
339
+
340
+ After starting infrastructure, write the SpiceDB schema and create group membership:
341
+
342
+ ```bash
343
+ openclaw graphiti-mem schema-write
344
+ openclaw graphiti-mem add-member main my-agent --type agent
345
+ ```
346
+
347
+ The plugin's startup service also auto-creates membership for the configured `subjectId` in the `defaultGroupId`.
348
+
349
+ ### Migrating from an Existing Memory Plugin
350
+
351
+ The `import` command migrates workspace markdown files (the universal memory format across all OpenClaw memory plugins) into the Graphiti knowledge graph:
352
+
353
+ ```bash
354
+ # Preview what will be imported
355
+ openclaw graphiti-mem import --dry-run
356
+
357
+ # Import workspace files (USER.md, MEMORY.md, memory/*.md, etc.)
358
+ openclaw graphiti-mem import
359
+
360
+ # Also import session transcripts
361
+ openclaw graphiti-mem import --include-sessions
362
+ ```
363
+
364
+ Workspace files are imported to the configured `defaultGroupId`. Session transcripts are imported to per-session groups (`session-<id>`).
365
+
366
+ ### Session Logging
367
+
368
+ OpenClaw's JSONL session logging is always-on core behavior — memory-graphiti does not replace it. The plugin augments session context by:
369
+
370
+ - **Auto-capture**: Extracting entities and facts from conversation turns into the knowledge graph (`agent_end` hook)
371
+ - **Auto-recall**: Injecting relevant memories into the agent's context before each turn (`before_agent_start` hook)
372
+
373
+ The JSONL transcripts remain on disk as a historical record. If you switch back to another memory plugin, they're still available.
374
+
375
+ ## Development
376
+
377
+ ### Dev Scripts
378
+
379
+ Helper scripts for local development without Docker:
380
+
381
+ ```bash
382
+ scripts/dev-setup.sh # One-time setup: install FalkorDB, Graphiti, SpiceDB
383
+ scripts/dev-start.sh # Start all services with health checks
384
+ scripts/dev-stop.sh # Stop all services
385
+ scripts/dev-status.sh # Check service status
386
+ ```
387
+
388
+ ### Running Tests
389
+
390
+ ```bash
391
+ # Unit tests (80 tests, no running services required)
392
+ npm test
393
+
394
+ # E2E tests (13 tests, requires running infrastructure)
395
+ OPENCLAW_LIVE_TEST=1 npm run test:e2e
396
+ ```
397
+
398
+ ### Project Structure
399
+
400
+ ```
401
+ ├── index.ts # Plugin entry: tools, hooks, CLI, service
402
+ ├── config.ts # Config schema and validation
403
+ ├── graphiti.ts # Graphiti MCP HTTP client (JSON-RPC/SSE)
404
+ ├── spicedb.ts # SpiceDB gRPC client wrapper
405
+ ├── authorization.ts # Authorization logic (SpiceDB operations)
406
+ ├── search.ts # Multi-group parallel search, dedup, formatting
407
+ ├── schema.zed # SpiceDB authorization schema
408
+ ├── openclaw.plugin.json # Plugin manifest
409
+ ├── package.json
410
+ ├── docker/
411
+ │ ├── docker-compose.yml # Full infrastructure stack
412
+ │ └── .env.example # Environment variable template
413
+ ├── scripts/
414
+ │ ├── dev-setup.sh # One-time dev setup
415
+ │ ├── dev-start.sh # Start dev services
416
+ │ ├── dev-stop.sh # Stop dev services
417
+ │ └── dev-status.sh # Check service status
418
+ ├── index.test.ts # Plugin integration tests
419
+ ├── authorization.test.ts # Authorization unit tests
420
+ ├── search.test.ts # Search unit tests
421
+ ├── graphiti.test.ts # Graphiti client tests
422
+ ├── config.test.ts # Config parsing tests
423
+ └── e2e.test.ts # End-to-end tests (live services)
424
+ ```
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Authorization Logic
3
+ *
4
+ * Bridges SpiceDB and Graphiti by managing:
5
+ * - Looking up which group_ids a subject can access
6
+ * - Writing fragment authorization relationships when memories are stored
7
+ * - Checking delete permissions
8
+ */
9
+
10
+ import type { SpiceDbClient, RelationshipTuple } from "./spicedb.js";
11
+
12
+ // ============================================================================
13
+ // Types
14
+ // ============================================================================
15
+
16
+ export type Subject = {
17
+ type: "agent" | "person";
18
+ id: string;
19
+ };
20
+
21
+ export type FragmentRelationships = {
22
+ fragmentId: string;
23
+ groupId: string;
24
+ sharedBy: Subject;
25
+ involves?: Subject[];
26
+ };
27
+
28
+ // ============================================================================
29
+ // Authorization Operations
30
+ // ============================================================================
31
+
32
+ /**
33
+ * Look up all group IDs that a subject has access to.
34
+ * Returns group resource IDs from SpiceDB where the subject has the "access" permission.
35
+ */
36
+ export async function lookupAuthorizedGroups(
37
+ spicedb: SpiceDbClient,
38
+ subject: Subject,
39
+ ): Promise<string[]> {
40
+ return spicedb.lookupResources({
41
+ resourceType: "group",
42
+ permission: "access",
43
+ subjectType: subject.type,
44
+ subjectId: subject.id,
45
+ });
46
+ }
47
+
48
+ /**
49
+ * Look up all memory fragment IDs that a subject can view.
50
+ * Used for fine-grained post-filtering when needed.
51
+ */
52
+ export async function lookupViewableFragments(
53
+ spicedb: SpiceDbClient,
54
+ subject: Subject,
55
+ ): Promise<string[]> {
56
+ return spicedb.lookupResources({
57
+ resourceType: "memory_fragment",
58
+ permission: "view",
59
+ subjectType: subject.type,
60
+ subjectId: subject.id,
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Write authorization relationships for a newly stored memory fragment.
66
+ *
67
+ * Creates:
68
+ * - memory_fragment:<id> #source_group group:<groupId>
69
+ * - memory_fragment:<id> #shared_by <sharedBy>
70
+ * - memory_fragment:<id> #involves <person> (for each involved person)
71
+ */
72
+ export async function writeFragmentRelationships(
73
+ spicedb: SpiceDbClient,
74
+ params: FragmentRelationships,
75
+ ): Promise<void> {
76
+ const tuples: RelationshipTuple[] = [
77
+ {
78
+ resourceType: "memory_fragment",
79
+ resourceId: params.fragmentId,
80
+ relation: "source_group",
81
+ subjectType: "group",
82
+ subjectId: params.groupId,
83
+ },
84
+ {
85
+ resourceType: "memory_fragment",
86
+ resourceId: params.fragmentId,
87
+ relation: "shared_by",
88
+ subjectType: params.sharedBy.type,
89
+ subjectId: params.sharedBy.id,
90
+ },
91
+ ];
92
+
93
+ if (params.involves) {
94
+ for (const person of params.involves) {
95
+ tuples.push({
96
+ resourceType: "memory_fragment",
97
+ resourceId: params.fragmentId,
98
+ relation: "involves",
99
+ subjectType: person.type,
100
+ subjectId: person.id,
101
+ });
102
+ }
103
+ }
104
+
105
+ await spicedb.writeRelationships(tuples);
106
+ }
107
+
108
+ /**
109
+ * Remove all authorization relationships for a memory fragment.
110
+ * Called when deleting a memory.
111
+ */
112
+ export async function deleteFragmentRelationships(
113
+ spicedb: SpiceDbClient,
114
+ fragmentId: string,
115
+ params: FragmentRelationships,
116
+ ): Promise<void> {
117
+ const tuples: RelationshipTuple[] = [
118
+ {
119
+ resourceType: "memory_fragment",
120
+ resourceId: fragmentId,
121
+ relation: "source_group",
122
+ subjectType: "group",
123
+ subjectId: params.groupId,
124
+ },
125
+ {
126
+ resourceType: "memory_fragment",
127
+ resourceId: fragmentId,
128
+ relation: "shared_by",
129
+ subjectType: params.sharedBy.type,
130
+ subjectId: params.sharedBy.id,
131
+ },
132
+ ];
133
+
134
+ if (params.involves) {
135
+ for (const person of params.involves) {
136
+ tuples.push({
137
+ resourceType: "memory_fragment",
138
+ resourceId: fragmentId,
139
+ relation: "involves",
140
+ subjectType: person.type,
141
+ subjectId: person.id,
142
+ });
143
+ }
144
+ }
145
+
146
+ await spicedb.deleteRelationships(tuples);
147
+ }
148
+
149
+ /**
150
+ * Check if a subject has delete permission on a memory fragment.
151
+ */
152
+ export async function canDeleteFragment(
153
+ spicedb: SpiceDbClient,
154
+ subject: Subject,
155
+ fragmentId: string,
156
+ ): Promise<boolean> {
157
+ return spicedb.checkPermission({
158
+ resourceType: "memory_fragment",
159
+ resourceId: fragmentId,
160
+ permission: "delete",
161
+ subjectType: subject.type,
162
+ subjectId: subject.id,
163
+ });
164
+ }
165
+
166
+ /**
167
+ * Check if a subject has write (contribute) permission on a group.
168
+ * Used to gate writes to non-session groups — prevents unauthorized memory injection.
169
+ */
170
+ export async function canWriteToGroup(
171
+ spicedb: SpiceDbClient,
172
+ subject: Subject,
173
+ groupId: string,
174
+ ): Promise<boolean> {
175
+ return spicedb.checkPermission({
176
+ resourceType: "group",
177
+ resourceId: groupId,
178
+ permission: "contribute",
179
+ subjectType: subject.type,
180
+ subjectId: subject.id,
181
+ });
182
+ }
183
+
184
+ /**
185
+ * Ensure a subject is registered as a member of a group.
186
+ * Idempotent (uses TOUCH operation).
187
+ */
188
+ export async function ensureGroupMembership(
189
+ spicedb: SpiceDbClient,
190
+ groupId: string,
191
+ member: Subject,
192
+ ): Promise<void> {
193
+ await spicedb.writeRelationships([
194
+ {
195
+ resourceType: "group",
196
+ resourceId: groupId,
197
+ relation: "member",
198
+ subjectType: member.type,
199
+ subjectId: member.id,
200
+ },
201
+ ]);
202
+ }