@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 +81 -32
- package/authorization.ts +27 -38
- package/bin/graphiti-mem.ts +140 -0
- package/cli.ts +522 -0
- package/docker/docker-compose.yml +15 -15
- package/graphiti.ts +75 -6
- package/index.ts +164 -338
- package/package.json +16 -6
- package/search.ts +17 -5
- package/spicedb.ts +190 -9
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# @openclaw
|
|
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
|
-
##
|
|
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
|
|
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.
|
|
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
|
|
72
|
+
openclaw gateway restart
|
|
77
73
|
```
|
|
78
74
|
|
|
79
|
-
|
|
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
|
-
|
|
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
|
|
319
|
+
### Infrastructure (default)
|
|
275
320
|
|
|
276
321
|
```bash
|
|
277
|
-
docker compose up -d
|
|
322
|
+
docker compose up -d
|
|
278
323
|
```
|
|
279
324
|
|
|
280
|
-
###
|
|
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<
|
|
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,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);
|