@contextableai/openclaw-memory-rebac 0.1.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 +464 -0
- package/authorization.ts +191 -0
- package/backend.ts +176 -0
- package/backends/backends.json +3 -0
- package/backends/graphiti.defaults.json +8 -0
- package/backends/graphiti.test.ts +292 -0
- package/backends/graphiti.ts +345 -0
- package/backends/registry.ts +36 -0
- package/bin/rebac-mem.ts +144 -0
- package/cli.ts +418 -0
- package/config.ts +141 -0
- package/docker/docker-compose.yml +17 -0
- package/docker/graphiti/Dockerfile +35 -0
- package/docker/graphiti/config_overlay.py +44 -0
- package/docker/graphiti/docker-compose.yml +101 -0
- package/docker/graphiti/graphiti_overlay.py +141 -0
- package/docker/graphiti/startup.py +222 -0
- package/docker/spicedb/docker-compose.yml +79 -0
- package/index.ts +711 -0
- package/openclaw.plugin.json +118 -0
- package/package.json +70 -0
- package/plugin.defaults.json +12 -0
- package/schema.zed +23 -0
- package/search.ts +139 -0
- package/spicedb.ts +355 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GraphitiBackend — MemoryBackend implementation backed by the Graphiti FastAPI REST server.
|
|
3
|
+
*
|
|
4
|
+
* Graphiti communicates via standard HTTP REST endpoints.
|
|
5
|
+
* Episodes are processed asynchronously by Graphiti's LLM pipeline;
|
|
6
|
+
* the real server-side UUID is discovered by polling GET /episodes/{group_id}.
|
|
7
|
+
*
|
|
8
|
+
* store() returns immediately; fragmentId resolves once Graphiti finishes
|
|
9
|
+
* processing and the UUID becomes visible in the episodes list.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
import type { Command } from "commander";
|
|
14
|
+
import type {
|
|
15
|
+
MemoryBackend,
|
|
16
|
+
SearchResult,
|
|
17
|
+
StoreResult,
|
|
18
|
+
ConversationTurn,
|
|
19
|
+
BackendDataset,
|
|
20
|
+
} from "../backend.js";
|
|
21
|
+
|
|
22
|
+
// ============================================================================
|
|
23
|
+
// Types (Graphiti REST API)
|
|
24
|
+
// ============================================================================
|
|
25
|
+
|
|
26
|
+
/** Matches the server's Message schema (from /openapi.json). */
|
|
27
|
+
type GraphitiMessage = {
|
|
28
|
+
content: string;
|
|
29
|
+
role_type: "user" | "assistant" | "system";
|
|
30
|
+
role: string | null;
|
|
31
|
+
name?: string;
|
|
32
|
+
timestamp?: string;
|
|
33
|
+
source_description?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type AddMessagesRequest = {
|
|
37
|
+
group_id: string;
|
|
38
|
+
messages: GraphitiMessage[];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type GraphitiEpisode = {
|
|
42
|
+
uuid: string;
|
|
43
|
+
name: string;
|
|
44
|
+
content: string;
|
|
45
|
+
source_description: string;
|
|
46
|
+
group_id: string;
|
|
47
|
+
created_at: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type FactResult = {
|
|
51
|
+
uuid: string;
|
|
52
|
+
name: string;
|
|
53
|
+
fact: string;
|
|
54
|
+
valid_at: string | null;
|
|
55
|
+
invalid_at: string | null;
|
|
56
|
+
created_at: string;
|
|
57
|
+
expired_at: string | null;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
type SearchRequest = {
|
|
61
|
+
group_ids: string[];
|
|
62
|
+
query: string;
|
|
63
|
+
max_facts?: number;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
type SearchResults = {
|
|
67
|
+
facts: FactResult[];
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
type GraphitiResult = {
|
|
71
|
+
message: string;
|
|
72
|
+
success: boolean;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// ============================================================================
|
|
76
|
+
// GraphitiBackend
|
|
77
|
+
// ============================================================================
|
|
78
|
+
|
|
79
|
+
export type GraphitiConfig = {
|
|
80
|
+
endpoint: string;
|
|
81
|
+
defaultGroupId: string;
|
|
82
|
+
uuidPollIntervalMs: number;
|
|
83
|
+
uuidPollMaxAttempts: number;
|
|
84
|
+
requestTimeoutMs?: number;
|
|
85
|
+
customInstructions: string;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export class GraphitiBackend implements MemoryBackend {
|
|
89
|
+
readonly name = "graphiti";
|
|
90
|
+
|
|
91
|
+
readonly uuidPollIntervalMs: number;
|
|
92
|
+
readonly uuidPollMaxAttempts: number;
|
|
93
|
+
private readonly requestTimeoutMs: number;
|
|
94
|
+
|
|
95
|
+
constructor(private readonly config: GraphitiConfig) {
|
|
96
|
+
this.uuidPollIntervalMs = config.uuidPollIntervalMs;
|
|
97
|
+
this.uuidPollMaxAttempts = config.uuidPollMaxAttempts;
|
|
98
|
+
this.requestTimeoutMs = config.requestTimeoutMs ?? 30000;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --------------------------------------------------------------------------
|
|
102
|
+
// REST transport
|
|
103
|
+
// --------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
private async restCall<T>(
|
|
106
|
+
method: "GET" | "POST" | "DELETE",
|
|
107
|
+
path: string,
|
|
108
|
+
body?: unknown,
|
|
109
|
+
): Promise<T> {
|
|
110
|
+
const url = `${this.config.endpoint}${path}`;
|
|
111
|
+
const opts: RequestInit = {
|
|
112
|
+
method,
|
|
113
|
+
headers: { "Content-Type": "application/json" },
|
|
114
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
|
115
|
+
};
|
|
116
|
+
if (body !== undefined) {
|
|
117
|
+
opts.body = JSON.stringify(body);
|
|
118
|
+
}
|
|
119
|
+
const response = await fetch(url, opts);
|
|
120
|
+
if (!response.ok) {
|
|
121
|
+
const text = await response.text().catch(() => "");
|
|
122
|
+
throw new Error(`Graphiti REST ${method} ${path} failed: ${response.status} ${text}`);
|
|
123
|
+
}
|
|
124
|
+
const ct = response.headers.get("content-type") ?? "";
|
|
125
|
+
if (ct.includes("application/json")) {
|
|
126
|
+
return (await response.json()) as T;
|
|
127
|
+
}
|
|
128
|
+
return {} as T;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --------------------------------------------------------------------------
|
|
132
|
+
// MemoryBackend implementation
|
|
133
|
+
// --------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
async store(params: {
|
|
136
|
+
content: string;
|
|
137
|
+
groupId: string;
|
|
138
|
+
sourceDescription?: string;
|
|
139
|
+
customPrompt?: string;
|
|
140
|
+
}): Promise<StoreResult> {
|
|
141
|
+
const episodeName = `memory_${randomUUID()}`;
|
|
142
|
+
let effectiveBody = params.content;
|
|
143
|
+
if (params.customPrompt) {
|
|
144
|
+
effectiveBody = `[Extraction Instructions]\n${params.customPrompt}\n[End Instructions]\n\n${params.content}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const request: AddMessagesRequest = {
|
|
148
|
+
group_id: params.groupId,
|
|
149
|
+
messages: [
|
|
150
|
+
{
|
|
151
|
+
name: episodeName,
|
|
152
|
+
content: effectiveBody,
|
|
153
|
+
timestamp: new Date().toISOString(),
|
|
154
|
+
role_type: "user",
|
|
155
|
+
role: "user",
|
|
156
|
+
source_description: params.sourceDescription,
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
await this.restCall<GraphitiResult>("POST", "/messages", request);
|
|
162
|
+
|
|
163
|
+
// POST /messages returns 202 (async processing).
|
|
164
|
+
// Poll GET /episodes until the episode appears, then return its real UUID.
|
|
165
|
+
const fragmentId = this.resolveEpisodeUuid(episodeName, params.groupId);
|
|
166
|
+
fragmentId.catch(() => {}); // Prevent unhandled rejection if caller drops it
|
|
167
|
+
|
|
168
|
+
return { fragmentId };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private async resolveEpisodeUuid(name: string, groupId: string): Promise<string> {
|
|
172
|
+
for (let i = 0; i < this.uuidPollMaxAttempts; i++) {
|
|
173
|
+
await new Promise((r) => setTimeout(r, this.uuidPollIntervalMs));
|
|
174
|
+
try {
|
|
175
|
+
const episodes = await this.getEpisodes(groupId, 50);
|
|
176
|
+
const match = episodes.find((ep) => ep.name === name);
|
|
177
|
+
if (match) return match.uuid;
|
|
178
|
+
} catch {
|
|
179
|
+
// Transient error — keep polling
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
throw new Error(`Timed out resolving episode UUID for "${name}" in group "${groupId}"`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async searchGroup(params: {
|
|
186
|
+
query: string;
|
|
187
|
+
groupId: string;
|
|
188
|
+
limit: number;
|
|
189
|
+
sessionId?: string;
|
|
190
|
+
}): Promise<SearchResult[]> {
|
|
191
|
+
const { query, groupId, limit } = params;
|
|
192
|
+
|
|
193
|
+
const searchRequest: SearchRequest = {
|
|
194
|
+
group_ids: [groupId],
|
|
195
|
+
query,
|
|
196
|
+
max_facts: limit,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const response = await this.restCall<SearchResults>("POST", "/search", searchRequest);
|
|
200
|
+
const facts = response.facts ?? [];
|
|
201
|
+
|
|
202
|
+
return facts.map((f) => ({
|
|
203
|
+
type: "fact" as const,
|
|
204
|
+
uuid: f.uuid,
|
|
205
|
+
group_id: groupId,
|
|
206
|
+
summary: f.fact,
|
|
207
|
+
context: f.name,
|
|
208
|
+
created_at: f.created_at,
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async getConversationHistory(sessionId: string, lastN = 10): Promise<ConversationTurn[]> {
|
|
213
|
+
const sessionGroup = `session-${sessionId.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
|
|
214
|
+
try {
|
|
215
|
+
const episodes = await this.getEpisodes(sessionGroup, lastN);
|
|
216
|
+
return episodes.map((ep) => ({
|
|
217
|
+
query: ep.name,
|
|
218
|
+
answer: ep.content,
|
|
219
|
+
created_at: ep.created_at,
|
|
220
|
+
}));
|
|
221
|
+
} catch {
|
|
222
|
+
return [];
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async healthCheck(): Promise<boolean> {
|
|
227
|
+
try {
|
|
228
|
+
const response = await fetch(`${this.config.endpoint}/healthcheck`, {
|
|
229
|
+
signal: AbortSignal.timeout(5000),
|
|
230
|
+
});
|
|
231
|
+
return response.ok;
|
|
232
|
+
} catch {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async getStatus(): Promise<Record<string, unknown>> {
|
|
238
|
+
return {
|
|
239
|
+
backend: "graphiti",
|
|
240
|
+
endpoint: this.config.endpoint,
|
|
241
|
+
healthy: await this.healthCheck(),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async deleteGroup(groupId: string): Promise<void> {
|
|
246
|
+
await this.restCall<GraphitiResult>(
|
|
247
|
+
"DELETE",
|
|
248
|
+
`/group/${encodeURIComponent(groupId)}`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async listGroups(): Promise<BackendDataset[]> {
|
|
253
|
+
// Graphiti has no list-groups API; the CLI can query SpiceDB for this
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async deleteFragment(uuid: string): Promise<boolean> {
|
|
258
|
+
await this.restCall<GraphitiResult>(
|
|
259
|
+
"DELETE",
|
|
260
|
+
`/episode/${encodeURIComponent(uuid)}`,
|
|
261
|
+
);
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// --------------------------------------------------------------------------
|
|
266
|
+
// Graphiti-specific helpers (used by CLI commands and UUID polling)
|
|
267
|
+
// --------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
async getEpisodes(groupId: string, lastN: number): Promise<GraphitiEpisode[]> {
|
|
270
|
+
return this.restCall<GraphitiEpisode[]>(
|
|
271
|
+
"GET",
|
|
272
|
+
`/episodes/${encodeURIComponent(groupId)}?last_n=${lastN}`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async getEntityEdge(uuid: string): Promise<FactResult> {
|
|
277
|
+
return this.restCall<FactResult>(
|
|
278
|
+
"GET",
|
|
279
|
+
`/entity-edge/${encodeURIComponent(uuid)}`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// --------------------------------------------------------------------------
|
|
284
|
+
// Backend-specific CLI commands
|
|
285
|
+
// --------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
registerCliCommands(cmd: Command): void {
|
|
288
|
+
cmd
|
|
289
|
+
.command("episodes")
|
|
290
|
+
.description("[graphiti] List recent episodes for a group")
|
|
291
|
+
.option("--last <n>", "Number of episodes", "10")
|
|
292
|
+
.option("--group <id>", "Group ID")
|
|
293
|
+
.action(async (opts: { last: string; group?: string }) => {
|
|
294
|
+
const groupId = opts.group ?? this.config.defaultGroupId;
|
|
295
|
+
const episodes = await this.getEpisodes(groupId, parseInt(opts.last));
|
|
296
|
+
console.log(JSON.stringify(episodes, null, 2));
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
cmd
|
|
300
|
+
.command("fact")
|
|
301
|
+
.description("[graphiti] Get a specific fact (entity edge) by UUID")
|
|
302
|
+
.argument("<uuid>", "Fact UUID")
|
|
303
|
+
.action(async (uuid: string) => {
|
|
304
|
+
try {
|
|
305
|
+
const fact = await this.getEntityEdge(uuid);
|
|
306
|
+
console.log(JSON.stringify(fact, null, 2));
|
|
307
|
+
} catch (err) {
|
|
308
|
+
console.error(`Failed to get fact: ${err instanceof Error ? err.message : String(err)}`);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
cmd
|
|
313
|
+
.command("clear-graph")
|
|
314
|
+
.description("[graphiti] Clear graph data for a group (destructive!)")
|
|
315
|
+
.option("--group <id...>", "Group ID(s)")
|
|
316
|
+
.option("--confirm", "Required safety flag", false)
|
|
317
|
+
.action(async (opts: { group?: string[]; confirm: boolean }) => {
|
|
318
|
+
if (!opts.confirm) {
|
|
319
|
+
console.log("Destructive operation. Pass --confirm to proceed.");
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const groups = opts.group ?? [];
|
|
323
|
+
if (groups.length === 0) {
|
|
324
|
+
console.log("No groups specified. Use --group <id> to specify groups.");
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
for (const g of groups) {
|
|
328
|
+
await this.deleteGroup(g);
|
|
329
|
+
console.log(`Cleared group: ${g}`);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ============================================================================
|
|
336
|
+
// Backend module exports (used by backends/registry.ts)
|
|
337
|
+
// ============================================================================
|
|
338
|
+
|
|
339
|
+
import graphitiDefaults from "./graphiti.defaults.json" with { type: "json" };
|
|
340
|
+
|
|
341
|
+
export const defaults: Record<string, unknown> = graphitiDefaults;
|
|
342
|
+
|
|
343
|
+
export function create(config: Record<string, unknown>): MemoryBackend {
|
|
344
|
+
return new GraphitiBackend(config as GraphitiConfig);
|
|
345
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backend registry — loaded dynamically from backends.json.
|
|
3
|
+
*
|
|
4
|
+
* Call initRegistry() once (e.g. at the start of register()) before using
|
|
5
|
+
* backendRegistry or createBackend(). No backend names appear in this file.
|
|
6
|
+
*
|
|
7
|
+
* To add a new backend:
|
|
8
|
+
* 1. Create backends/<name>.ts (exports `defaults` and `create`)
|
|
9
|
+
* 2. Create backends/<name>.defaults.json
|
|
10
|
+
* 3. Add `"<name>": "./<name>.js"` to backends/backends.json
|
|
11
|
+
* No TypeScript changes needed anywhere else.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import backendsJson from "./backends.json" with { type: "json" };
|
|
15
|
+
import type { MemoryBackend } from "../backend.js";
|
|
16
|
+
|
|
17
|
+
export type BackendModule = {
|
|
18
|
+
create: (config: Record<string, unknown>) => MemoryBackend;
|
|
19
|
+
defaults: Record<string, unknown>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Mutable backing store — populated by initRegistry().
|
|
23
|
+
// backendRegistry is a live reference to the same object.
|
|
24
|
+
const _registry: Record<string, BackendModule> = {};
|
|
25
|
+
|
|
26
|
+
export async function initRegistry(): Promise<void> {
|
|
27
|
+
if (Object.keys(_registry).length > 0) return;
|
|
28
|
+
for (const [name, modulePath] of Object.entries(backendsJson as Record<string, string>)) {
|
|
29
|
+
const url = new URL(modulePath, import.meta.url);
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
31
|
+
const mod = await import(url.href) as any;
|
|
32
|
+
_registry[name] = mod as BackendModule;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const backendRegistry: Readonly<Record<string, BackendModule>> = _registry;
|
package/bin/rebac-mem.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Standalone CLI entry point for rebac-mem commands.
|
|
4
|
+
*
|
|
5
|
+
* Reads config from environment variables and/or a JSON config file,
|
|
6
|
+
* instantiates SpiceDB + selected backend, and exposes the same commands
|
|
7
|
+
* as the OpenClaw plugin — without requiring a running gateway.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* npx tsx bin/rebac-mem.ts <command> [options]
|
|
11
|
+
* npm run cli -- <command> [options]
|
|
12
|
+
*
|
|
13
|
+
* Config priority (highest first):
|
|
14
|
+
* 1. Environment variables
|
|
15
|
+
* 2. rebac-mem.config.json (current directory)
|
|
16
|
+
* 3. ~/.config/rebac-mem/config.json
|
|
17
|
+
* 4. Defaults
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { Command } from "commander";
|
|
21
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
22
|
+
import { resolve, join } from "node:path";
|
|
23
|
+
import { homedir } from "node:os";
|
|
24
|
+
import { rebacMemoryConfigSchema, createBackend } from "../config.js";
|
|
25
|
+
import { SpiceDbClient } from "../spicedb.js";
|
|
26
|
+
import { registerCommands } from "../cli.js";
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Config loading
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
function loadConfigFile(configPath?: string): Record<string, unknown> | null {
|
|
33
|
+
const candidates = configPath
|
|
34
|
+
? [resolve(configPath)]
|
|
35
|
+
: [
|
|
36
|
+
resolve("rebac-mem.config.json"),
|
|
37
|
+
join(homedir(), ".config", "rebac-mem", "config.json"),
|
|
38
|
+
];
|
|
39
|
+
for (const path of candidates) {
|
|
40
|
+
if (existsSync(path)) {
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.error(`Failed to parse config file ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function loadConfigFromEnv(): Record<string, unknown> {
|
|
53
|
+
const env = process.env;
|
|
54
|
+
const config: Record<string, unknown> = {};
|
|
55
|
+
|
|
56
|
+
if (env.REBAC_MEM_BACKEND) config.backend = env.REBAC_MEM_BACKEND;
|
|
57
|
+
|
|
58
|
+
// SpiceDB config
|
|
59
|
+
const spicedb: Record<string, unknown> = {};
|
|
60
|
+
if (env.REBAC_MEM_SPICEDB_TOKEN ?? env.SPICEDB_TOKEN)
|
|
61
|
+
spicedb.token = env.REBAC_MEM_SPICEDB_TOKEN ?? env.SPICEDB_TOKEN;
|
|
62
|
+
if (env.REBAC_MEM_SPICEDB_ENDPOINT ?? env.SPICEDB_ENDPOINT)
|
|
63
|
+
spicedb.endpoint = env.REBAC_MEM_SPICEDB_ENDPOINT ?? env.SPICEDB_ENDPOINT;
|
|
64
|
+
if (env.REBAC_MEM_SPICEDB_INSECURE)
|
|
65
|
+
spicedb.insecure = env.REBAC_MEM_SPICEDB_INSECURE !== "false";
|
|
66
|
+
if (Object.keys(spicedb).length > 0) config.spicedb = spicedb;
|
|
67
|
+
|
|
68
|
+
// Graphiti config
|
|
69
|
+
const graphiti: Record<string, unknown> = {};
|
|
70
|
+
if (env.REBAC_MEM_GRAPHITI_ENDPOINT ?? env.GRAPHITI_ENDPOINT)
|
|
71
|
+
graphiti.endpoint = env.REBAC_MEM_GRAPHITI_ENDPOINT ?? env.GRAPHITI_ENDPOINT;
|
|
72
|
+
if (env.REBAC_MEM_DEFAULT_GROUP_ID)
|
|
73
|
+
graphiti.defaultGroupId = env.REBAC_MEM_DEFAULT_GROUP_ID;
|
|
74
|
+
if (Object.keys(graphiti).length > 0) config.graphiti = graphiti;
|
|
75
|
+
|
|
76
|
+
// Top-level config
|
|
77
|
+
if (env.REBAC_MEM_SUBJECT_TYPE) config.subjectType = env.REBAC_MEM_SUBJECT_TYPE;
|
|
78
|
+
if (env.REBAC_MEM_SUBJECT_ID) config.subjectId = env.REBAC_MEM_SUBJECT_ID;
|
|
79
|
+
|
|
80
|
+
return config;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function deepMerge(
|
|
84
|
+
base: Record<string, unknown>,
|
|
85
|
+
override: Record<string, unknown>,
|
|
86
|
+
): Record<string, unknown> {
|
|
87
|
+
const result = { ...base };
|
|
88
|
+
for (const key of Object.keys(override)) {
|
|
89
|
+
if (
|
|
90
|
+
typeof result[key] === "object" && result[key] !== null && !Array.isArray(result[key]) &&
|
|
91
|
+
typeof override[key] === "object" && override[key] !== null && !Array.isArray(override[key])
|
|
92
|
+
) {
|
|
93
|
+
result[key] = deepMerge(
|
|
94
|
+
result[key] as Record<string, unknown>,
|
|
95
|
+
override[key] as Record<string, unknown>,
|
|
96
|
+
);
|
|
97
|
+
} else {
|
|
98
|
+
result[key] = override[key];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ============================================================================
|
|
105
|
+
// Main
|
|
106
|
+
// ============================================================================
|
|
107
|
+
|
|
108
|
+
const program = new Command()
|
|
109
|
+
.name("rebac-mem")
|
|
110
|
+
.description("Standalone CLI for ReBAC memory management (Graphiti + SpiceDB)")
|
|
111
|
+
.option("--config <path>", "Path to config JSON file");
|
|
112
|
+
|
|
113
|
+
const configIdx = process.argv.indexOf("--config");
|
|
114
|
+
const configPath = configIdx !== -1 ? process.argv[configIdx + 1] : undefined;
|
|
115
|
+
|
|
116
|
+
const fileConfig = loadConfigFile(configPath);
|
|
117
|
+
const envConfig = loadConfigFromEnv();
|
|
118
|
+
|
|
119
|
+
const mergedConfig = fileConfig ? deepMerge(fileConfig, envConfig) : envConfig;
|
|
120
|
+
|
|
121
|
+
let cfg;
|
|
122
|
+
try {
|
|
123
|
+
cfg = rebacMemoryConfigSchema.parse(mergedConfig);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
console.error(`Invalid configuration: ${err instanceof Error ? err.message : String(err)}`);
|
|
126
|
+
console.error("\nProvide config via environment variables or a JSON config file.");
|
|
127
|
+
console.error("Required: SPICEDB_TOKEN (or --config with spicedb.token)");
|
|
128
|
+
console.error("Optional: REBAC_MEM_BACKEND=graphiti (default: graphiti)");
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const backend = createBackend(cfg);
|
|
133
|
+
const spicedb = new SpiceDbClient(cfg.spicedb);
|
|
134
|
+
const currentSubject = { type: cfg.subjectType, id: cfg.subjectId } as const;
|
|
135
|
+
|
|
136
|
+
registerCommands(program, {
|
|
137
|
+
backend,
|
|
138
|
+
spicedb,
|
|
139
|
+
cfg,
|
|
140
|
+
currentSubject,
|
|
141
|
+
getLastWriteToken: () => undefined,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
await program.parseAsync(process.argv);
|