@contextableai/openclaw-memory-graphiti 0.1.2 → 0.2.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 CHANGED
@@ -253,6 +253,56 @@ All commands are under `graphiti-mem`:
253
253
  | `graphiti-mem add-member <group-id> <subject-id>` | Add a subject to a group. Options: `--type` |
254
254
  | `graphiti-mem import` | Import workspace markdown files into Graphiti. Options: `--workspace`, `--include-sessions`, `--session-dir`, `--group`, `--dry-run` |
255
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
+
256
306
  ## Docker Compose
257
307
 
258
308
  The `docker/` directory contains a full-stack Docker Compose configuration:
@@ -394,6 +444,7 @@ OPENCLAW_LIVE_TEST=1 npm run test:e2e
394
444
 
395
445
  ```
396
446
  ├── index.ts # Plugin entry: tools, hooks, CLI, service
447
+ ├── cli.ts # Shared CLI commands (used by plugin + standalone)
397
448
  ├── config.ts # Config schema and validation
398
449
  ├── graphiti.ts # Graphiti MCP HTTP client (JSON-RPC/SSE)
399
450
  ├── spicedb.ts # SpiceDB gRPC client wrapper
@@ -402,6 +453,8 @@ OPENCLAW_LIVE_TEST=1 npm run test:e2e
402
453
  ├── schema.zed # SpiceDB authorization schema
403
454
  ├── openclaw.plugin.json # Plugin manifest
404
455
  ├── package.json
456
+ ├── bin/
457
+ │ └── graphiti-mem.ts # Standalone CLI entry point
405
458
  ├── docker/
406
459
  │ ├── docker-compose.yml # Full infrastructure stack
407
460
  │ └── .env.example # Environment variable template
@@ -411,6 +464,7 @@ OPENCLAW_LIVE_TEST=1 npm run test:e2e
411
464
  │ ├── dev-stop.sh # Stop dev services
412
465
  │ └── dev-status.sh # Check service status
413
466
  ├── index.test.ts # Plugin integration tests
467
+ ├── cli.test.ts # CLI module tests
414
468
  ├── authorization.test.ts # Authorization unit tests
415
469
  ├── search.test.ts # Search unit tests
416
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,142 @@
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
+ graphiti.uuidPollIntervalMs = cfg.graphiti.uuidPollIntervalMs;
130
+ graphiti.uuidPollMaxAttempts = cfg.graphiti.uuidPollMaxAttempts;
131
+ const spicedb = new SpiceDbClient(cfg.spicedb);
132
+ const currentSubject = { type: cfg.subjectType, id: cfg.subjectId } as const;
133
+
134
+ registerCommands(program, {
135
+ graphiti,
136
+ spicedb,
137
+ cfg,
138
+ currentSubject,
139
+ getLastWriteToken: () => undefined,
140
+ });
141
+
142
+ await program.parseAsync(process.argv);