@contextableai/openclaw-memory-rebac 0.1.0 → 0.1.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.
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Authorization Logic
3
+ *
4
+ * Bridges SpiceDB and the memory backend by managing:
5
+ * - Looking up which group_ids a subject can access
6
+ * - Writing fragment authorization relationships when memories are stored
7
+ * - Checking delete permissions
8
+ */
9
+ import type { SpiceDbClient } from "./spicedb.js";
10
+ export type Subject = {
11
+ type: "agent" | "person";
12
+ id: string;
13
+ };
14
+ export type FragmentRelationships = {
15
+ fragmentId: string;
16
+ groupId: string;
17
+ sharedBy: Subject;
18
+ involves?: Subject[];
19
+ };
20
+ /**
21
+ * Look up all group IDs that a subject has access to.
22
+ * Returns group resource IDs from SpiceDB where the subject has the "access" permission.
23
+ */
24
+ export declare function lookupAuthorizedGroups(spicedb: SpiceDbClient, subject: Subject, zedToken?: string): Promise<string[]>;
25
+ /**
26
+ * Look up all memory fragment IDs that a subject can view.
27
+ * Used for fine-grained post-filtering when needed.
28
+ */
29
+ export declare function lookupViewableFragments(spicedb: SpiceDbClient, subject: Subject, zedToken?: string): Promise<string[]>;
30
+ /**
31
+ * Write authorization relationships for a newly stored memory fragment.
32
+ *
33
+ * Creates:
34
+ * - memory_fragment:<id> #source_group group:<groupId>
35
+ * - memory_fragment:<id> #shared_by <sharedBy>
36
+ * - memory_fragment:<id> #involves <person> (for each involved person)
37
+ */
38
+ export declare function writeFragmentRelationships(spicedb: SpiceDbClient, params: FragmentRelationships): Promise<string | undefined>;
39
+ /**
40
+ * Remove all authorization relationships for a memory fragment.
41
+ * Uses filter-based deletion — no need to know the group, sharer, or involved parties.
42
+ */
43
+ export declare function deleteFragmentRelationships(spicedb: SpiceDbClient, fragmentId: string): Promise<string | undefined>;
44
+ /**
45
+ * Check if a subject has delete permission on a memory fragment.
46
+ */
47
+ export declare function canDeleteFragment(spicedb: SpiceDbClient, subject: Subject, fragmentId: string, zedToken?: string): Promise<boolean>;
48
+ /**
49
+ * Check if a subject has write (contribute) permission on a group.
50
+ * Used to gate writes to non-session groups — prevents unauthorized memory injection.
51
+ */
52
+ export declare function canWriteToGroup(spicedb: SpiceDbClient, subject: Subject, groupId: string, zedToken?: string): Promise<boolean>;
53
+ /**
54
+ * Ensure a subject is registered as a member of a group.
55
+ * Idempotent (uses TOUCH operation).
56
+ */
57
+ export declare function ensureGroupMembership(spicedb: SpiceDbClient, groupId: string, member: Subject): Promise<string | undefined>;
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Authorization Logic
3
+ *
4
+ * Bridges SpiceDB and the memory backend by managing:
5
+ * - Looking up which group_ids a subject can access
6
+ * - Writing fragment authorization relationships when memories are stored
7
+ * - Checking delete permissions
8
+ */
9
+ // ============================================================================
10
+ // Helpers
11
+ // ============================================================================
12
+ function tokenConsistency(zedToken) {
13
+ return zedToken ? { mode: "at_least_as_fresh", token: zedToken } : undefined;
14
+ }
15
+ // ============================================================================
16
+ // Authorization Operations
17
+ // ============================================================================
18
+ /**
19
+ * Look up all group IDs that a subject has access to.
20
+ * Returns group resource IDs from SpiceDB where the subject has the "access" permission.
21
+ */
22
+ export async function lookupAuthorizedGroups(spicedb, subject, zedToken) {
23
+ return spicedb.lookupResources({
24
+ resourceType: "group",
25
+ permission: "access",
26
+ subjectType: subject.type,
27
+ subjectId: subject.id,
28
+ consistency: tokenConsistency(zedToken),
29
+ });
30
+ }
31
+ /**
32
+ * Look up all memory fragment IDs that a subject can view.
33
+ * Used for fine-grained post-filtering when needed.
34
+ */
35
+ export async function lookupViewableFragments(spicedb, subject, zedToken) {
36
+ return spicedb.lookupResources({
37
+ resourceType: "memory_fragment",
38
+ permission: "view",
39
+ subjectType: subject.type,
40
+ subjectId: subject.id,
41
+ consistency: tokenConsistency(zedToken),
42
+ });
43
+ }
44
+ /**
45
+ * Write authorization relationships for a newly stored memory fragment.
46
+ *
47
+ * Creates:
48
+ * - memory_fragment:<id> #source_group group:<groupId>
49
+ * - memory_fragment:<id> #shared_by <sharedBy>
50
+ * - memory_fragment:<id> #involves <person> (for each involved person)
51
+ */
52
+ export async function writeFragmentRelationships(spicedb, params) {
53
+ const tuples = [
54
+ {
55
+ resourceType: "memory_fragment",
56
+ resourceId: params.fragmentId,
57
+ relation: "source_group",
58
+ subjectType: "group",
59
+ subjectId: params.groupId,
60
+ },
61
+ {
62
+ resourceType: "memory_fragment",
63
+ resourceId: params.fragmentId,
64
+ relation: "shared_by",
65
+ subjectType: params.sharedBy.type,
66
+ subjectId: params.sharedBy.id,
67
+ },
68
+ ];
69
+ if (params.involves) {
70
+ for (const person of params.involves) {
71
+ tuples.push({
72
+ resourceType: "memory_fragment",
73
+ resourceId: params.fragmentId,
74
+ relation: "involves",
75
+ subjectType: person.type,
76
+ subjectId: person.id,
77
+ });
78
+ }
79
+ }
80
+ return spicedb.writeRelationships(tuples);
81
+ }
82
+ /**
83
+ * Remove all authorization relationships for a memory fragment.
84
+ * Uses filter-based deletion — no need to know the group, sharer, or involved parties.
85
+ */
86
+ export async function deleteFragmentRelationships(spicedb, fragmentId) {
87
+ return spicedb.deleteRelationshipsByFilter({
88
+ resourceType: "memory_fragment",
89
+ resourceId: fragmentId,
90
+ });
91
+ }
92
+ /**
93
+ * Check if a subject has delete permission on a memory fragment.
94
+ */
95
+ export async function canDeleteFragment(spicedb, subject, fragmentId, zedToken) {
96
+ return spicedb.checkPermission({
97
+ resourceType: "memory_fragment",
98
+ resourceId: fragmentId,
99
+ permission: "delete",
100
+ subjectType: subject.type,
101
+ subjectId: subject.id,
102
+ consistency: tokenConsistency(zedToken),
103
+ });
104
+ }
105
+ /**
106
+ * Check if a subject has write (contribute) permission on a group.
107
+ * Used to gate writes to non-session groups — prevents unauthorized memory injection.
108
+ */
109
+ export async function canWriteToGroup(spicedb, subject, groupId, zedToken) {
110
+ return spicedb.checkPermission({
111
+ resourceType: "group",
112
+ resourceId: groupId,
113
+ permission: "contribute",
114
+ subjectType: subject.type,
115
+ subjectId: subject.id,
116
+ consistency: tokenConsistency(zedToken),
117
+ });
118
+ }
119
+ /**
120
+ * Ensure a subject is registered as a member of a group.
121
+ * Idempotent (uses TOUCH operation).
122
+ */
123
+ export async function ensureGroupMembership(spicedb, groupId, member) {
124
+ return spicedb.writeRelationships([
125
+ {
126
+ resourceType: "group",
127
+ resourceId: groupId,
128
+ relation: "member",
129
+ subjectType: member.type,
130
+ subjectId: member.id,
131
+ },
132
+ ]);
133
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * MemoryBackend interface
3
+ *
4
+ * All storage-engine specifics live here. The rest of the plugin
5
+ * (SpiceDB auth, search orchestration, tool registration, CLI skeleton)
6
+ * is backend-agnostic and never imports from backends/.
7
+ *
8
+ * Implementing a new backend means satisfying this interface and adding
9
+ * an entry to the factory in config.ts. That's it.
10
+ */
11
+ import type { Command } from "commander";
12
+ /**
13
+ * A single memory item returned by searchGroup().
14
+ * Backends are responsible for mapping their native result shape to this.
15
+ */
16
+ export type SearchResult = {
17
+ /** "node"/"fact" for graph backends; "chunk"/"summary"/"completion" for doc backends */
18
+ type: "node" | "fact" | "chunk" | "summary" | "completion";
19
+ /** Stable ID used by memory_forget. Must be unique within the backend. */
20
+ uuid: string;
21
+ group_id: string;
22
+ summary: string;
23
+ /** Human-readable context hint (entity names, dataset, etc.) */
24
+ context: string;
25
+ created_at: string;
26
+ /** Relevance score [0,1] when available */
27
+ score?: number;
28
+ };
29
+ /**
30
+ * Returned by store(). The fragmentId resolves to the UUID that will be
31
+ * registered in SpiceDB.
32
+ *
33
+ * - Graphiti: Resolves once the server has processed the episode (polled in the background).
34
+ *
35
+ * index.ts chains SpiceDB writeFragmentRelationships() to this Promise,
36
+ * so it always fires at the right time.
37
+ */
38
+ export type StoreResult = {
39
+ fragmentId: Promise<string>;
40
+ };
41
+ /**
42
+ * A single conversation turn, used for episodic recall.
43
+ * Backends that don't support conversation history return [].
44
+ */
45
+ export type ConversationTurn = {
46
+ query: string;
47
+ answer: string;
48
+ context?: string;
49
+ created_at?: string;
50
+ };
51
+ /**
52
+ * Minimal dataset descriptor for the CLI `datasets` command.
53
+ */
54
+ export type BackendDataset = {
55
+ name: string;
56
+ /** Group ID this dataset maps to (backend-specific derivation) */
57
+ groupId: string;
58
+ /** Optional backend-specific dataset ID */
59
+ id?: string;
60
+ };
61
+ export interface MemoryBackend {
62
+ /** Human-readable backend name for logs and status output */
63
+ readonly name: string;
64
+ /**
65
+ * Ingest content into the group's storage partition.
66
+ * The returned StoreResult.fragmentId resolves when the backend has
67
+ * produced a stable UUID suitable for SpiceDB registration.
68
+ */
69
+ store(params: {
70
+ content: string;
71
+ groupId: string;
72
+ sourceDescription?: string;
73
+ customPrompt?: string;
74
+ }): Promise<StoreResult>;
75
+ /**
76
+ * Search within a single group's storage partition.
77
+ * Called in parallel per-group by searchAuthorizedMemories() in search.ts.
78
+ * Backends map their native result shape to SearchResult[].
79
+ */
80
+ searchGroup(params: {
81
+ query: string;
82
+ groupId: string;
83
+ limit: number;
84
+ sessionId?: string;
85
+ }): Promise<SearchResult[]>;
86
+ /**
87
+ * Optional backend-specific session enrichment, called from agent_end
88
+ * after store() for conversation auto-capture.
89
+ *
90
+ * Graphiti: no-op (addEpisode already handles episodic memory).
91
+ */
92
+ enrichSession?(params: {
93
+ sessionId: string;
94
+ groupId: string;
95
+ userMsg: string;
96
+ assistantMsg: string;
97
+ }): Promise<void>;
98
+ /**
99
+ * Retrieve conversation history for a session.
100
+ * Graphiti maps getEpisodes() to this shape.
101
+ * Backends that don't support it return [].
102
+ */
103
+ getConversationHistory(sessionId: string, lastN?: number): Promise<ConversationTurn[]>;
104
+ healthCheck(): Promise<boolean>;
105
+ getStatus(): Promise<Record<string, unknown>>;
106
+ /**
107
+ * Delete a group's entire storage partition.
108
+ * Used by `rebac-mem clear-group --confirm`.
109
+ */
110
+ deleteGroup(groupId: string): Promise<void>;
111
+ /**
112
+ * List all storage partitions (datasets/groups) managed by this backend.
113
+ */
114
+ listGroups(): Promise<BackendDataset[]>;
115
+ /**
116
+ * Delete a single memory fragment by UUID.
117
+ * Optional: not all backends support sub-dataset deletion.
118
+ * Returns true if deleted, false if the backend doesn't support it.
119
+ */
120
+ deleteFragment?(uuid: string): Promise<boolean>;
121
+ /**
122
+ * Discover fragment (fact/edge) UUIDs that were extracted from a stored episode.
123
+ * Called after store() resolves the episode ID to write per-fragment SpiceDB
124
+ * relationships with the correct fact-level UUIDs.
125
+ * Optional: not all backends separate episodes from fragments.
126
+ */
127
+ discoverFragmentIds?(episodeId: string): Promise<string[]>;
128
+ /**
129
+ * Register backend-specific CLI subcommands onto the shared `rebac-mem` command.
130
+ * Called once during CLI setup. Backend may register any commands it needs.
131
+ *
132
+ * Example: Graphiti registers episodes, fact, clear-graph
133
+ */
134
+ registerCliCommands?(cmd: Command): void;
135
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * MemoryBackend interface
3
+ *
4
+ * All storage-engine specifics live here. The rest of the plugin
5
+ * (SpiceDB auth, search orchestration, tool registration, CLI skeleton)
6
+ * is backend-agnostic and never imports from backends/.
7
+ *
8
+ * Implementing a new backend means satisfying this interface and adding
9
+ * an entry to the factory in config.ts. That's it.
10
+ */
11
+ export {};
@@ -0,0 +1,72 @@
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
+ import type { Command } from "commander";
12
+ import type { MemoryBackend, SearchResult, StoreResult, ConversationTurn, BackendDataset } from "../backend.js";
13
+ type GraphitiEpisode = {
14
+ uuid: string;
15
+ name: string;
16
+ content: string;
17
+ source_description: string;
18
+ group_id: string;
19
+ created_at: string;
20
+ };
21
+ type FactResult = {
22
+ uuid: string;
23
+ name: string;
24
+ fact: string;
25
+ valid_at: string | null;
26
+ invalid_at: string | null;
27
+ created_at: string;
28
+ expired_at: string | null;
29
+ };
30
+ export type GraphitiConfig = {
31
+ endpoint: string;
32
+ defaultGroupId: string;
33
+ uuidPollIntervalMs: number;
34
+ uuidPollMaxAttempts: number;
35
+ requestTimeoutMs?: number;
36
+ customInstructions: string;
37
+ };
38
+ export declare class GraphitiBackend implements MemoryBackend {
39
+ private readonly config;
40
+ readonly name = "graphiti";
41
+ readonly uuidPollIntervalMs: number;
42
+ readonly uuidPollMaxAttempts: number;
43
+ private readonly requestTimeoutMs;
44
+ constructor(config: GraphitiConfig);
45
+ private restCall;
46
+ store(params: {
47
+ content: string;
48
+ groupId: string;
49
+ sourceDescription?: string;
50
+ customPrompt?: string;
51
+ }): Promise<StoreResult>;
52
+ private resolveEpisodeUuid;
53
+ searchGroup(params: {
54
+ query: string;
55
+ groupId: string;
56
+ limit: number;
57
+ sessionId?: string;
58
+ }): Promise<SearchResult[]>;
59
+ getConversationHistory(sessionId: string, lastN?: number): Promise<ConversationTurn[]>;
60
+ healthCheck(): Promise<boolean>;
61
+ getStatus(): Promise<Record<string, unknown>>;
62
+ deleteGroup(groupId: string): Promise<void>;
63
+ listGroups(): Promise<BackendDataset[]>;
64
+ deleteFragment(uuid: string): Promise<boolean>;
65
+ getEpisodes(groupId: string, lastN: number): Promise<GraphitiEpisode[]>;
66
+ discoverFragmentIds(episodeId: string): Promise<string[]>;
67
+ getEntityEdge(uuid: string): Promise<FactResult>;
68
+ registerCliCommands(cmd: Command): void;
69
+ }
70
+ export declare const defaults: Record<string, unknown>;
71
+ export declare function create(config: Record<string, unknown>): MemoryBackend;
72
+ export {};
@@ -0,0 +1,222 @@
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
+ import { randomUUID } from "node:crypto";
12
+ export class GraphitiBackend {
13
+ config;
14
+ name = "graphiti";
15
+ uuidPollIntervalMs;
16
+ uuidPollMaxAttempts;
17
+ requestTimeoutMs;
18
+ constructor(config) {
19
+ this.config = config;
20
+ this.uuidPollIntervalMs = config.uuidPollIntervalMs;
21
+ this.uuidPollMaxAttempts = config.uuidPollMaxAttempts;
22
+ this.requestTimeoutMs = config.requestTimeoutMs ?? 30000;
23
+ }
24
+ // --------------------------------------------------------------------------
25
+ // REST transport
26
+ // --------------------------------------------------------------------------
27
+ async restCall(method, path, body) {
28
+ const url = `${this.config.endpoint}${path}`;
29
+ const opts = {
30
+ method,
31
+ headers: { "Content-Type": "application/json" },
32
+ signal: AbortSignal.timeout(this.requestTimeoutMs),
33
+ };
34
+ if (body !== undefined) {
35
+ opts.body = JSON.stringify(body);
36
+ }
37
+ const response = await fetch(url, opts);
38
+ if (!response.ok) {
39
+ const text = await response.text().catch(() => "");
40
+ throw new Error(`Graphiti REST ${method} ${path} failed: ${response.status} ${text}`);
41
+ }
42
+ const ct = response.headers.get("content-type") ?? "";
43
+ if (ct.includes("application/json")) {
44
+ return (await response.json());
45
+ }
46
+ return {};
47
+ }
48
+ // --------------------------------------------------------------------------
49
+ // MemoryBackend implementation
50
+ // --------------------------------------------------------------------------
51
+ async store(params) {
52
+ const episodeName = `memory_${randomUUID()}`;
53
+ let effectiveBody = params.content;
54
+ if (params.customPrompt) {
55
+ effectiveBody = `[Extraction Instructions]\n${params.customPrompt}\n[End Instructions]\n\n${params.content}`;
56
+ }
57
+ const request = {
58
+ group_id: params.groupId,
59
+ messages: [
60
+ {
61
+ name: episodeName,
62
+ content: effectiveBody,
63
+ timestamp: new Date().toISOString(),
64
+ role_type: "user",
65
+ role: "user",
66
+ source_description: params.sourceDescription,
67
+ },
68
+ ],
69
+ };
70
+ await this.restCall("POST", "/messages", request);
71
+ // POST /messages returns 202 (async processing).
72
+ // Poll GET /episodes until the episode appears, then return its real UUID.
73
+ const fragmentId = this.resolveEpisodeUuid(episodeName, params.groupId);
74
+ fragmentId.catch(() => { }); // Prevent unhandled rejection if caller drops it
75
+ return { fragmentId };
76
+ }
77
+ async resolveEpisodeUuid(name, groupId) {
78
+ for (let i = 0; i < this.uuidPollMaxAttempts; i++) {
79
+ await new Promise((r) => setTimeout(r, this.uuidPollIntervalMs));
80
+ try {
81
+ const episodes = await this.getEpisodes(groupId, 50);
82
+ const match = episodes.find((ep) => ep.name === name);
83
+ if (match)
84
+ return match.uuid;
85
+ }
86
+ catch {
87
+ // Transient error — keep polling
88
+ }
89
+ }
90
+ throw new Error(`Timed out resolving episode UUID for "${name}" in group "${groupId}"`);
91
+ }
92
+ async searchGroup(params) {
93
+ const { query, groupId, limit } = params;
94
+ const searchRequest = {
95
+ group_ids: [groupId],
96
+ query,
97
+ max_facts: limit,
98
+ };
99
+ const response = await this.restCall("POST", "/search", searchRequest);
100
+ const facts = response.facts ?? [];
101
+ return facts.map((f) => ({
102
+ type: "fact",
103
+ uuid: f.uuid,
104
+ group_id: groupId,
105
+ summary: f.fact,
106
+ context: f.name,
107
+ created_at: f.created_at,
108
+ }));
109
+ }
110
+ async getConversationHistory(sessionId, lastN = 10) {
111
+ const sessionGroup = `session-${sessionId.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
112
+ try {
113
+ const episodes = await this.getEpisodes(sessionGroup, lastN);
114
+ return episodes.map((ep) => ({
115
+ query: ep.name,
116
+ answer: ep.content,
117
+ created_at: ep.created_at,
118
+ }));
119
+ }
120
+ catch {
121
+ return [];
122
+ }
123
+ }
124
+ async healthCheck() {
125
+ try {
126
+ const response = await fetch(`${this.config.endpoint}/healthcheck`, {
127
+ signal: AbortSignal.timeout(5000),
128
+ });
129
+ return response.ok;
130
+ }
131
+ catch {
132
+ return false;
133
+ }
134
+ }
135
+ async getStatus() {
136
+ return {
137
+ backend: "graphiti",
138
+ endpoint: this.config.endpoint,
139
+ healthy: await this.healthCheck(),
140
+ };
141
+ }
142
+ async deleteGroup(groupId) {
143
+ await this.restCall("DELETE", `/group/${encodeURIComponent(groupId)}`);
144
+ }
145
+ async listGroups() {
146
+ // Graphiti has no list-groups API; the CLI can query SpiceDB for this
147
+ return [];
148
+ }
149
+ async deleteFragment(uuid) {
150
+ await this.restCall("DELETE", `/episode/${encodeURIComponent(uuid)}`);
151
+ return true;
152
+ }
153
+ // --------------------------------------------------------------------------
154
+ // Graphiti-specific helpers (used by CLI commands and UUID polling)
155
+ // --------------------------------------------------------------------------
156
+ async getEpisodes(groupId, lastN) {
157
+ return this.restCall("GET", `/episodes/${encodeURIComponent(groupId)}?last_n=${lastN}`);
158
+ }
159
+ async discoverFragmentIds(episodeId) {
160
+ const edges = await this.restCall("GET", `/episodes/${encodeURIComponent(episodeId)}/edges`);
161
+ return edges.map((e) => e.uuid);
162
+ }
163
+ async getEntityEdge(uuid) {
164
+ return this.restCall("GET", `/entity-edge/${encodeURIComponent(uuid)}`);
165
+ }
166
+ // --------------------------------------------------------------------------
167
+ // Backend-specific CLI commands
168
+ // --------------------------------------------------------------------------
169
+ registerCliCommands(cmd) {
170
+ cmd
171
+ .command("episodes")
172
+ .description("[graphiti] List recent episodes for a group")
173
+ .option("--last <n>", "Number of episodes", "10")
174
+ .option("--group <id>", "Group ID")
175
+ .action(async (opts) => {
176
+ const groupId = opts.group ?? this.config.defaultGroupId;
177
+ const episodes = await this.getEpisodes(groupId, parseInt(opts.last));
178
+ console.log(JSON.stringify(episodes, null, 2));
179
+ });
180
+ cmd
181
+ .command("fact")
182
+ .description("[graphiti] Get a specific fact (entity edge) by UUID")
183
+ .argument("<uuid>", "Fact UUID")
184
+ .action(async (uuid) => {
185
+ try {
186
+ const fact = await this.getEntityEdge(uuid);
187
+ console.log(JSON.stringify(fact, null, 2));
188
+ }
189
+ catch (err) {
190
+ console.error(`Failed to get fact: ${err instanceof Error ? err.message : String(err)}`);
191
+ }
192
+ });
193
+ cmd
194
+ .command("clear-graph")
195
+ .description("[graphiti] Clear graph data for a group (destructive!)")
196
+ .option("--group <id...>", "Group ID(s)")
197
+ .option("--confirm", "Required safety flag", false)
198
+ .action(async (opts) => {
199
+ if (!opts.confirm) {
200
+ console.log("Destructive operation. Pass --confirm to proceed.");
201
+ return;
202
+ }
203
+ const groups = opts.group ?? [];
204
+ if (groups.length === 0) {
205
+ console.log("No groups specified. Use --group <id> to specify groups.");
206
+ return;
207
+ }
208
+ for (const g of groups) {
209
+ await this.deleteGroup(g);
210
+ console.log(`Cleared group: ${g}`);
211
+ }
212
+ });
213
+ }
214
+ }
215
+ // ============================================================================
216
+ // Backend module exports (used by backends/registry.ts)
217
+ // ============================================================================
218
+ import graphitiDefaults from "./graphiti.defaults.json" with { type: "json" };
219
+ export const defaults = graphitiDefaults;
220
+ export function create(config) {
221
+ return new GraphitiBackend(config);
222
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Backend registry — statically imported from backends.json keys.
3
+ *
4
+ * To add a new backend:
5
+ * 1. Create backends/<name>.ts (exports `defaults` and `create`)
6
+ * 2. Create backends/<name>.defaults.json
7
+ * 3. Import and register it here
8
+ */
9
+ import type { MemoryBackend } from "../backend.js";
10
+ export type BackendModule = {
11
+ create: (config: Record<string, unknown>) => MemoryBackend;
12
+ defaults: Record<string, unknown>;
13
+ };
14
+ export declare const backendRegistry: Readonly<Record<string, BackendModule>>;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Backend registry — statically imported from backends.json keys.
3
+ *
4
+ * To add a new backend:
5
+ * 1. Create backends/<name>.ts (exports `defaults` and `create`)
6
+ * 2. Create backends/<name>.defaults.json
7
+ * 3. Import and register it here
8
+ */
9
+ import * as graphiti from "./graphiti.js";
10
+ export const backendRegistry = {
11
+ graphiti,
12
+ };
package/dist/cli.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Shared CLI command registration for rebac-mem.
3
+ *
4
+ * Registers backend-agnostic commands (search, status, schema-write, groups,
5
+ * add-member, import) then calls backend.registerCliCommands() for
6
+ * backend-specific extensions (e.g., graphiti: episodes, fact, clear-graph).
7
+ *
8
+ * Used by both the OpenClaw plugin (index.ts) and the standalone CLI
9
+ * (bin/rebac-mem.ts).
10
+ */
11
+ import type { Command } from "commander";
12
+ import type { MemoryBackend } from "./backend.js";
13
+ import type { SpiceDbClient } from "./spicedb.js";
14
+ import type { RebacMemoryConfig } from "./config.js";
15
+ import { type Subject } from "./authorization.js";
16
+ export type CliContext = {
17
+ backend: MemoryBackend;
18
+ spicedb: SpiceDbClient;
19
+ cfg: RebacMemoryConfig;
20
+ currentSubject: Subject;
21
+ getLastWriteToken: () => string | undefined;
22
+ };
23
+ export declare function registerCommands(cmd: Command, ctx: CliContext): void;