@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.
@@ -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;
@@ -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);