@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 +54 -0
- package/authorization.ts +27 -38
- package/bin/graphiti-mem.ts +142 -0
- package/cli.ts +525 -0
- package/config.ts +13 -1
- package/docker/docker-compose.yml +3 -2
- package/graphiti.ts +80 -7
- package/index.ts +154 -361
- package/openclaw.plugin.json +15 -1
- package/package.json +11 -6
- package/scripts/dev-start.sh +4 -0
- package/search.ts +34 -18
- package/spicedb.ts +190 -9
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<
|
|
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
|
-
|
|
117
|
+
return spicedb.writeRelationships(tuples);
|
|
106
118
|
}
|
|
107
119
|
|
|
108
120
|
/**
|
|
109
121
|
* Remove all authorization relationships for a memory fragment.
|
|
110
|
-
*
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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<
|
|
193
|
-
|
|
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);
|