@contextableai/openclaw-memory-graphiti 0.1.1 → 0.2.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 CHANGED
@@ -1,4 +1,4 @@
1
- # @openclaw/memory-graphiti
1
+ # @contextableai/openclaw-memory-graphiti
2
2
 
3
3
  Two-layer memory plugin for OpenClaw: **SpiceDB** for authorization, **Graphiti** for knowledge graph storage.
4
4
 
@@ -30,7 +30,21 @@ Agents remember conversations as structured entities and facts in a knowledge gr
30
30
 
31
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
32
 
33
- ## Quick Start
33
+ ## Installation
34
+
35
+ ```bash
36
+ openclaw plugins install @contextableai/openclaw-memory-graphiti
37
+ ```
38
+
39
+ Or with npm:
40
+
41
+ ```bash
42
+ npm install @contextableai/openclaw-memory-graphiti
43
+ ```
44
+
45
+ Then restart the gateway. On first start, the plugin automatically:
46
+ - Writes the SpiceDB authorization schema (if not already present)
47
+ - Creates group membership for the configured agent in the default group
34
48
 
35
49
  ### Prerequisites
36
50
 
@@ -43,45 +57,26 @@ Agents remember conversations as structured entities and facts in a knowledge gr
43
57
  cd docker
44
58
  cp .env.example .env
45
59
  # Edit .env — set OPENAI_API_KEY at minimum
46
- docker compose up -d falkordb graphiti-mcp spicedb
60
+ docker compose up -d
47
61
  ```
48
62
 
49
63
  This starts:
50
64
  - **FalkorDB** on port 6379 (graph database, web UI on port 3000)
51
65
  - **Graphiti MCP Server** on port 8000 (knowledge graph API)
66
+ - **PostgreSQL** on port 5432 (persistent datastore for SpiceDB)
52
67
  - **SpiceDB** on port 50051 (authorization engine)
53
68
 
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
69
+ ### 2. Restart the Gateway
74
70
 
75
71
  ```bash
76
- openclaw graphiti-mem schema-write
72
+ openclaw gateway restart
77
73
  ```
78
74
 
79
- ### 4. Add Group Membership
75
+ The plugin auto-initializes on startup — no manual `schema-write` or `add-member` needed for basic use. The SpiceDB schema is written automatically on first run, and the configured `subjectId` is added to the `defaultGroupId`.
80
76
 
81
- ```bash
82
- # Add the agent to a group
83
- openclaw graphiti-mem add-member main my-agent --type agent
77
+ ### 3. (Optional) Add More Group Members
84
78
 
79
+ ```bash
85
80
  # Add people to groups
86
81
  openclaw graphiti-mem add-member family mom --type person
87
82
  openclaw graphiti-mem add-member family dad --type person
@@ -258,6 +253,56 @@ All commands are under `graphiti-mem`:
258
253
  | `graphiti-mem add-member <group-id> <subject-id>` | Add a subject to a group. Options: `--type` |
259
254
  | `graphiti-mem import` | Import workspace markdown files into Graphiti. Options: `--workspace`, `--include-sessions`, `--session-dir`, `--group`, `--dry-run` |
260
255
 
256
+ ### Standalone CLI
257
+
258
+ For development and testing, commands can be run directly without a full OpenClaw gateway:
259
+
260
+ ```bash
261
+ # Via npm script
262
+ npm run cli -- status
263
+ npm run cli -- search "some query"
264
+ npm run cli -- import --workspace /path/to/files --dry-run
265
+
266
+ # Via npx
267
+ npx tsx bin/graphiti-mem.ts cleanup --dry-run
268
+ ```
269
+
270
+ **Configuration** is loaded from (highest priority first):
271
+
272
+ 1. **Environment variables** — `SPICEDB_TOKEN`, `SPICEDB_ENDPOINT`, `GRAPHITI_ENDPOINT`, or prefixed variants (`GRAPHITI_MEM_SPICEDB_TOKEN`, etc.)
273
+ 2. **JSON config file** — `--config <path>`, or auto-discovered from `./graphiti-mem.config.json` or `~/.config/graphiti-mem/config.json`
274
+ 3. **Built-in defaults** (see [Configuration Reference](#configuration-reference))
275
+
276
+ Example config file (`graphiti-mem.config.json`):
277
+
278
+ ```json
279
+ {
280
+ "spicedb": {
281
+ "endpoint": "localhost:50051",
282
+ "token": "dev_token",
283
+ "insecure": true
284
+ },
285
+ "graphiti": {
286
+ "endpoint": "http://localhost:8000",
287
+ "defaultGroupId": "main"
288
+ },
289
+ "subjectType": "agent",
290
+ "subjectId": "my-agent"
291
+ }
292
+ ```
293
+
294
+ | Environment Variable | Config Equivalent |
295
+ |---------------------|-------------------|
296
+ | `SPICEDB_TOKEN` | `spicedb.token` |
297
+ | `SPICEDB_ENDPOINT` | `spicedb.endpoint` |
298
+ | `GRAPHITI_ENDPOINT` | `graphiti.endpoint` |
299
+ | `GRAPHITI_MEM_SPICEDB_INSECURE` | `spicedb.insecure` |
300
+ | `GRAPHITI_MEM_DEFAULT_GROUP_ID` | `graphiti.defaultGroupId` |
301
+ | `GRAPHITI_MEM_SUBJECT_TYPE` | `subjectType` |
302
+ | `GRAPHITI_MEM_SUBJECT_ID` | `subjectId` |
303
+
304
+ The plugin-registered CLI (`openclaw graphiti-mem ...`) remains the primary interface for end users. The standalone CLI is a developer convenience.
305
+
261
306
  ## Docker Compose
262
307
 
263
308
  The `docker/` directory contains a full-stack Docker Compose configuration:
@@ -271,16 +316,16 @@ The `docker/` directory contains a full-stack Docker Compose configuration:
271
316
  | `spicedb` | 50051, 8443, 9090 | Authorization engine (gRPC, HTTP, metrics) |
272
317
  | `openclaw-gateway` | 18789, 18790 | OpenClaw gateway (optional) |
273
318
 
274
- ### Infrastructure Only
319
+ ### Infrastructure (default)
275
320
 
276
321
  ```bash
277
- docker compose up -d falkordb graphiti-mcp postgres spicedb-migrate spicedb
322
+ docker compose up -d
278
323
  ```
279
324
 
280
- ### Full Stack (Gateway + Infrastructure)
325
+ ### With OpenClaw Gateway
281
326
 
282
327
  ```bash
283
- docker compose up -d
328
+ docker compose --profile gateway up -d
284
329
  ```
285
330
 
286
331
  When running inside Docker Compose, use service hostnames in the plugin config:
@@ -399,6 +444,7 @@ OPENCLAW_LIVE_TEST=1 npm run test:e2e
399
444
 
400
445
  ```
401
446
  ├── index.ts # Plugin entry: tools, hooks, CLI, service
447
+ ├── cli.ts # Shared CLI commands (used by plugin + standalone)
402
448
  ├── config.ts # Config schema and validation
403
449
  ├── graphiti.ts # Graphiti MCP HTTP client (JSON-RPC/SSE)
404
450
  ├── spicedb.ts # SpiceDB gRPC client wrapper
@@ -407,6 +453,8 @@ OPENCLAW_LIVE_TEST=1 npm run test:e2e
407
453
  ├── schema.zed # SpiceDB authorization schema
408
454
  ├── openclaw.plugin.json # Plugin manifest
409
455
  ├── package.json
456
+ ├── bin/
457
+ │ └── graphiti-mem.ts # Standalone CLI entry point
410
458
  ├── docker/
411
459
  │ ├── docker-compose.yml # Full infrastructure stack
412
460
  │ └── .env.example # Environment variable template
@@ -416,6 +464,7 @@ OPENCLAW_LIVE_TEST=1 npm run test:e2e
416
464
  │ ├── dev-stop.sh # Stop dev services
417
465
  │ └── dev-status.sh # Check service status
418
466
  ├── index.test.ts # Plugin integration tests
467
+ ├── cli.test.ts # CLI module tests
419
468
  ├── authorization.test.ts # Authorization unit tests
420
469
  ├── search.test.ts # Search unit tests
421
470
  ├── graphiti.test.ts # Graphiti client tests
package/authorization.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * - Checking delete permissions
8
8
  */
9
9
 
10
- import type { SpiceDbClient, RelationshipTuple } from "./spicedb.js";
10
+ import type { SpiceDbClient, RelationshipTuple, ConsistencyMode } from "./spicedb.js";
11
11
 
12
12
  // ============================================================================
13
13
  // Types
@@ -25,6 +25,14 @@ export type FragmentRelationships = {
25
25
  involves?: Subject[];
26
26
  };
27
27
 
28
+ // ============================================================================
29
+ // Helpers
30
+ // ============================================================================
31
+
32
+ function tokenConsistency(zedToken?: string): ConsistencyMode | undefined {
33
+ return zedToken ? { mode: "at_least_as_fresh", token: zedToken } : undefined;
34
+ }
35
+
28
36
  // ============================================================================
29
37
  // Authorization Operations
30
38
  // ============================================================================
@@ -36,12 +44,14 @@ export type FragmentRelationships = {
36
44
  export async function lookupAuthorizedGroups(
37
45
  spicedb: SpiceDbClient,
38
46
  subject: Subject,
47
+ zedToken?: string,
39
48
  ): Promise<string[]> {
40
49
  return spicedb.lookupResources({
41
50
  resourceType: "group",
42
51
  permission: "access",
43
52
  subjectType: subject.type,
44
53
  subjectId: subject.id,
54
+ consistency: tokenConsistency(zedToken),
45
55
  });
46
56
  }
47
57
 
@@ -52,12 +62,14 @@ export async function lookupAuthorizedGroups(
52
62
  export async function lookupViewableFragments(
53
63
  spicedb: SpiceDbClient,
54
64
  subject: Subject,
65
+ zedToken?: string,
55
66
  ): Promise<string[]> {
56
67
  return spicedb.lookupResources({
57
68
  resourceType: "memory_fragment",
58
69
  permission: "view",
59
70
  subjectType: subject.type,
60
71
  subjectId: subject.id,
72
+ consistency: tokenConsistency(zedToken),
61
73
  });
62
74
  }
63
75
 
@@ -72,7 +84,7 @@ export async function lookupViewableFragments(
72
84
  export async function writeFragmentRelationships(
73
85
  spicedb: SpiceDbClient,
74
86
  params: FragmentRelationships,
75
- ): Promise<void> {
87
+ ): Promise<string | undefined> {
76
88
  const tuples: RelationshipTuple[] = [
77
89
  {
78
90
  resourceType: "memory_fragment",
@@ -102,48 +114,21 @@ export async function writeFragmentRelationships(
102
114
  }
103
115
  }
104
116
 
105
- await spicedb.writeRelationships(tuples);
117
+ return spicedb.writeRelationships(tuples);
106
118
  }
107
119
 
108
120
  /**
109
121
  * Remove all authorization relationships for a memory fragment.
110
- * Called when deleting a memory.
122
+ * Uses filter-based deletion no need to know the group, sharer, or involved parties.
111
123
  */
112
124
  export async function deleteFragmentRelationships(
113
125
  spicedb: SpiceDbClient,
114
126
  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);
127
+ ): Promise<string | undefined> {
128
+ return spicedb.deleteRelationshipsByFilter({
129
+ resourceType: "memory_fragment",
130
+ resourceId: fragmentId,
131
+ });
147
132
  }
148
133
 
149
134
  /**
@@ -153,6 +138,7 @@ export async function canDeleteFragment(
153
138
  spicedb: SpiceDbClient,
154
139
  subject: Subject,
155
140
  fragmentId: string,
141
+ zedToken?: string,
156
142
  ): Promise<boolean> {
157
143
  return spicedb.checkPermission({
158
144
  resourceType: "memory_fragment",
@@ -160,6 +146,7 @@ export async function canDeleteFragment(
160
146
  permission: "delete",
161
147
  subjectType: subject.type,
162
148
  subjectId: subject.id,
149
+ consistency: tokenConsistency(zedToken),
163
150
  });
164
151
  }
165
152
 
@@ -171,6 +158,7 @@ export async function canWriteToGroup(
171
158
  spicedb: SpiceDbClient,
172
159
  subject: Subject,
173
160
  groupId: string,
161
+ zedToken?: string,
174
162
  ): Promise<boolean> {
175
163
  return spicedb.checkPermission({
176
164
  resourceType: "group",
@@ -178,6 +166,7 @@ export async function canWriteToGroup(
178
166
  permission: "contribute",
179
167
  subjectType: subject.type,
180
168
  subjectId: subject.id,
169
+ consistency: tokenConsistency(zedToken),
181
170
  });
182
171
  }
183
172
 
@@ -189,8 +178,8 @@ export async function ensureGroupMembership(
189
178
  spicedb: SpiceDbClient,
190
179
  groupId: string,
191
180
  member: Subject,
192
- ): Promise<void> {
193
- await spicedb.writeRelationships([
181
+ ): Promise<string | undefined> {
182
+ return spicedb.writeRelationships([
194
183
  {
195
184
  resourceType: "group",
196
185
  resourceId: groupId,
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Standalone CLI entry point for graphiti-mem commands.
4
+ *
5
+ * Reads config from environment variables and/or a JSON config file,
6
+ * instantiates SpiceDB + Graphiti clients, and exposes the same commands
7
+ * as the OpenClaw plugin — without requiring a running gateway.
8
+ *
9
+ * Usage:
10
+ * npx tsx bin/graphiti-mem.ts <command> [options]
11
+ * npm run cli -- <command> [options]
12
+ */
13
+
14
+ import { Command } from "commander";
15
+ import { readFileSync, existsSync } from "node:fs";
16
+ import { resolve, join } from "node:path";
17
+ import { homedir } from "node:os";
18
+ import { graphitiMemoryConfigSchema } from "../config.js";
19
+ import { GraphitiClient } from "../graphiti.js";
20
+ import { SpiceDbClient } from "../spicedb.js";
21
+ import { registerCommands } from "../cli.js";
22
+
23
+ // ============================================================================
24
+ // Config loading
25
+ // ============================================================================
26
+
27
+ function loadConfigFile(configPath?: string): Record<string, unknown> | null {
28
+ const candidates = configPath
29
+ ? [resolve(configPath)]
30
+ : [
31
+ resolve("graphiti-mem.config.json"),
32
+ join(homedir(), ".config", "graphiti-mem", "config.json"),
33
+ ];
34
+ for (const path of candidates) {
35
+ if (existsSync(path)) {
36
+ try {
37
+ return JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
38
+ } catch (err) {
39
+ console.error(`Failed to parse config file ${path}: ${err instanceof Error ? err.message : String(err)}`);
40
+ process.exit(1);
41
+ }
42
+ }
43
+ }
44
+ return null;
45
+ }
46
+
47
+ function loadConfigFromEnv(): Record<string, unknown> {
48
+ const env = process.env;
49
+ const config: Record<string, unknown> = {};
50
+
51
+ // SpiceDB config
52
+ const spicedb: Record<string, unknown> = {};
53
+ if (env.GRAPHITI_MEM_SPICEDB_TOKEN ?? env.SPICEDB_TOKEN)
54
+ spicedb.token = env.GRAPHITI_MEM_SPICEDB_TOKEN ?? env.SPICEDB_TOKEN;
55
+ if (env.GRAPHITI_MEM_SPICEDB_ENDPOINT ?? env.SPICEDB_ENDPOINT)
56
+ spicedb.endpoint = env.GRAPHITI_MEM_SPICEDB_ENDPOINT ?? env.SPICEDB_ENDPOINT;
57
+ if (env.GRAPHITI_MEM_SPICEDB_INSECURE)
58
+ spicedb.insecure = env.GRAPHITI_MEM_SPICEDB_INSECURE !== "false";
59
+ if (Object.keys(spicedb).length > 0) config.spicedb = spicedb;
60
+
61
+ // Graphiti config
62
+ const graphiti: Record<string, unknown> = {};
63
+ if (env.GRAPHITI_MEM_GRAPHITI_ENDPOINT ?? env.GRAPHITI_ENDPOINT)
64
+ graphiti.endpoint = env.GRAPHITI_MEM_GRAPHITI_ENDPOINT ?? env.GRAPHITI_ENDPOINT;
65
+ if (env.GRAPHITI_MEM_DEFAULT_GROUP_ID)
66
+ graphiti.defaultGroupId = env.GRAPHITI_MEM_DEFAULT_GROUP_ID;
67
+ if (Object.keys(graphiti).length > 0) config.graphiti = graphiti;
68
+
69
+ // Top-level config
70
+ if (env.GRAPHITI_MEM_SUBJECT_TYPE) config.subjectType = env.GRAPHITI_MEM_SUBJECT_TYPE;
71
+ if (env.GRAPHITI_MEM_SUBJECT_ID) config.subjectId = env.GRAPHITI_MEM_SUBJECT_ID;
72
+
73
+ return config;
74
+ }
75
+
76
+ function deepMerge(
77
+ base: Record<string, unknown>,
78
+ override: Record<string, unknown>,
79
+ ): Record<string, unknown> {
80
+ const result = { ...base };
81
+ for (const key of Object.keys(override)) {
82
+ if (
83
+ typeof result[key] === "object" && result[key] !== null && !Array.isArray(result[key]) &&
84
+ typeof override[key] === "object" && override[key] !== null && !Array.isArray(override[key])
85
+ ) {
86
+ result[key] = deepMerge(
87
+ result[key] as Record<string, unknown>,
88
+ override[key] as Record<string, unknown>,
89
+ );
90
+ } else {
91
+ result[key] = override[key];
92
+ }
93
+ }
94
+ return result;
95
+ }
96
+
97
+ // ============================================================================
98
+ // Main
99
+ // ============================================================================
100
+
101
+ const program = new Command()
102
+ .name("graphiti-mem")
103
+ .description("Standalone CLI for Graphiti + SpiceDB memory management")
104
+ .option("--config <path>", "Path to config JSON file");
105
+
106
+ // Extract --config before subcommand parsing
107
+ const configIdx = process.argv.indexOf("--config");
108
+ const configPath = configIdx !== -1 ? process.argv[configIdx + 1] : undefined;
109
+
110
+ const fileConfig = loadConfigFile(configPath);
111
+ const envConfig = loadConfigFromEnv();
112
+
113
+ // Merge: env vars override file config, both override defaults
114
+ const mergedConfig = fileConfig
115
+ ? deepMerge(fileConfig, envConfig)
116
+ : envConfig;
117
+
118
+ let cfg;
119
+ try {
120
+ cfg = graphitiMemoryConfigSchema.parse(mergedConfig);
121
+ } catch (err) {
122
+ console.error(`Invalid configuration: ${err instanceof Error ? err.message : String(err)}`);
123
+ console.error("\nProvide config via environment variables or a JSON config file.");
124
+ console.error("Required: SPICEDB_TOKEN (or --config with spicedb.token)");
125
+ process.exit(1);
126
+ }
127
+
128
+ const graphiti = new GraphitiClient(cfg.graphiti.endpoint);
129
+ const spicedb = new SpiceDbClient(cfg.spicedb);
130
+ const currentSubject = { type: cfg.subjectType, id: cfg.subjectId } as const;
131
+
132
+ registerCommands(program, {
133
+ graphiti,
134
+ spicedb,
135
+ cfg,
136
+ currentSubject,
137
+ getLastWriteToken: () => undefined,
138
+ });
139
+
140
+ await program.parseAsync(process.argv);