@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.
- package/dist/authorization.d.ts +57 -0
- package/dist/authorization.js +133 -0
- package/dist/backend.d.ts +135 -0
- package/dist/backend.js +11 -0
- package/dist/backends/graphiti.d.ts +72 -0
- package/dist/backends/graphiti.js +222 -0
- package/dist/backends/registry.d.ts +14 -0
- package/dist/backends/registry.js +12 -0
- package/dist/cli.d.ts +23 -0
- package/dist/cli.js +446 -0
- package/dist/config.d.ts +34 -0
- package/dist/config.js +97 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +638 -0
- package/dist/plugin.defaults.json +12 -0
- package/dist/search.d.ts +34 -0
- package/dist/search.js +98 -0
- package/dist/spicedb.d.ts +80 -0
- package/dist/spicedb.js +256 -0
- package/docker/graphiti/.env +50 -0
- package/docker/graphiti/docker-compose.yml +2 -2
- package/docker/graphiti/graphiti_overlay.py +26 -1
- package/docker/graphiti/startup.py +58 -4
- package/docker/spicedb/.env +14 -0
- package/package.json +8 -11
- package/authorization.ts +0 -191
- package/backend.ts +0 -176
- package/backends/backends.json +0 -3
- package/backends/graphiti.test.ts +0 -292
- package/backends/graphiti.ts +0 -345
- package/backends/registry.ts +0 -36
- package/cli.ts +0 -418
- package/config.ts +0 -141
- package/index.ts +0 -711
- package/search.ts +0 -139
- package/spicedb.ts +0 -355
- /package/{backends → dist/backends}/graphiti.defaults.json +0 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
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 { readFileSync } from "node:fs";
|
|
12
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
13
|
+
import { join, dirname, basename, resolve } from "node:path";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { defaultGroupId } from "./config.js";
|
|
17
|
+
import { lookupAuthorizedGroups, ensureGroupMembership, } from "./authorization.js";
|
|
18
|
+
import { searchAuthorizedMemories } from "./search.js";
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// Session helper (mirrors index.ts — no shared module to avoid circular import)
|
|
21
|
+
// ============================================================================
|
|
22
|
+
function sessionGroupId(sessionId) {
|
|
23
|
+
const sanitized = sessionId.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
24
|
+
return `session-${sanitized}`;
|
|
25
|
+
}
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// Command Registration
|
|
28
|
+
// ============================================================================
|
|
29
|
+
export function registerCommands(cmd, ctx) {
|
|
30
|
+
const { backend, spicedb, cfg, currentSubject, getLastWriteToken } = ctx;
|
|
31
|
+
const defGroupId = defaultGroupId(cfg);
|
|
32
|
+
// --------------------------------------------------------------------------
|
|
33
|
+
// search
|
|
34
|
+
// --------------------------------------------------------------------------
|
|
35
|
+
cmd
|
|
36
|
+
.command("search")
|
|
37
|
+
.description("Search memories with authorization")
|
|
38
|
+
.argument("<query>", "Search query")
|
|
39
|
+
.option("--limit <n>", "Max results", "10")
|
|
40
|
+
.action(async (query, opts) => {
|
|
41
|
+
const authorizedGroups = await lookupAuthorizedGroups(spicedb, currentSubject, getLastWriteToken());
|
|
42
|
+
if (authorizedGroups.length === 0) {
|
|
43
|
+
console.log("No accessible memory groups.");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
console.log(`Searching ${authorizedGroups.length} authorized groups...`);
|
|
47
|
+
const results = await searchAuthorizedMemories(backend, {
|
|
48
|
+
query,
|
|
49
|
+
groupIds: authorizedGroups,
|
|
50
|
+
limit: parseInt(opts.limit),
|
|
51
|
+
});
|
|
52
|
+
if (results.length === 0) {
|
|
53
|
+
console.log("No results found.");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
console.log(JSON.stringify(results, null, 2));
|
|
57
|
+
});
|
|
58
|
+
// --------------------------------------------------------------------------
|
|
59
|
+
// status
|
|
60
|
+
// --------------------------------------------------------------------------
|
|
61
|
+
cmd
|
|
62
|
+
.command("status")
|
|
63
|
+
.description("Check backend + SpiceDB health")
|
|
64
|
+
.action(async () => {
|
|
65
|
+
const backendStatus = await backend.getStatus();
|
|
66
|
+
let spicedbOk = false;
|
|
67
|
+
try {
|
|
68
|
+
await spicedb.readSchema();
|
|
69
|
+
spicedbOk = true;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// unreachable
|
|
73
|
+
}
|
|
74
|
+
console.log(`Backend (${backend.name}): ${backendStatus.healthy ? "OK" : "UNREACHABLE"}`);
|
|
75
|
+
for (const [k, v] of Object.entries(backendStatus)) {
|
|
76
|
+
if (k !== "backend" && k !== "healthy")
|
|
77
|
+
console.log(` ${k}: ${v}`);
|
|
78
|
+
}
|
|
79
|
+
console.log(`SpiceDB: ${spicedbOk ? "OK" : "UNREACHABLE"} (${cfg.spicedb.endpoint})`);
|
|
80
|
+
});
|
|
81
|
+
// --------------------------------------------------------------------------
|
|
82
|
+
// schema-write
|
|
83
|
+
// --------------------------------------------------------------------------
|
|
84
|
+
cmd
|
|
85
|
+
.command("schema-write")
|
|
86
|
+
.description("Write/update SpiceDB authorization schema")
|
|
87
|
+
.action(async () => {
|
|
88
|
+
const schemaPath = join(dirname(fileURLToPath(import.meta.url)), "schema.zed");
|
|
89
|
+
const schema = readFileSync(schemaPath, "utf-8");
|
|
90
|
+
await spicedb.writeSchema(schema);
|
|
91
|
+
console.log("SpiceDB schema written successfully.");
|
|
92
|
+
});
|
|
93
|
+
// --------------------------------------------------------------------------
|
|
94
|
+
// groups
|
|
95
|
+
// --------------------------------------------------------------------------
|
|
96
|
+
cmd
|
|
97
|
+
.command("groups")
|
|
98
|
+
.description("List authorized groups for current subject")
|
|
99
|
+
.action(async () => {
|
|
100
|
+
const groups = await lookupAuthorizedGroups(spicedb, currentSubject, getLastWriteToken());
|
|
101
|
+
if (groups.length === 0) {
|
|
102
|
+
console.log("No authorized groups.");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
console.log(`Authorized groups for ${currentSubject.type}:${currentSubject.id}:`);
|
|
106
|
+
for (const g of groups)
|
|
107
|
+
console.log(` - ${g}`);
|
|
108
|
+
});
|
|
109
|
+
// --------------------------------------------------------------------------
|
|
110
|
+
// add-member
|
|
111
|
+
// --------------------------------------------------------------------------
|
|
112
|
+
cmd
|
|
113
|
+
.command("add-member")
|
|
114
|
+
.description("Add a subject to a group")
|
|
115
|
+
.argument("<group-id>", "Group ID")
|
|
116
|
+
.argument("<subject-id>", "Subject ID")
|
|
117
|
+
.option("--type <type>", "Subject type (agent|person)", "person")
|
|
118
|
+
.action(async (groupId, subjectId, opts) => {
|
|
119
|
+
const subjectType = opts.type === "agent" ? "agent" : "person";
|
|
120
|
+
await ensureGroupMembership(spicedb, groupId, {
|
|
121
|
+
type: subjectType,
|
|
122
|
+
id: subjectId,
|
|
123
|
+
});
|
|
124
|
+
console.log(`Added ${subjectType}:${subjectId} to group:${groupId}`);
|
|
125
|
+
});
|
|
126
|
+
// --------------------------------------------------------------------------
|
|
127
|
+
// import — ingest workspace files + session transcripts into the backend
|
|
128
|
+
// --------------------------------------------------------------------------
|
|
129
|
+
cmd
|
|
130
|
+
.command("import")
|
|
131
|
+
.description("Import workspace markdown files (and optionally session transcripts) into the backend")
|
|
132
|
+
.option("--workspace <path>", "Workspace directory", join(homedir(), ".openclaw", "workspace"))
|
|
133
|
+
.option("--include-sessions", "Also import session JSONL transcripts", false)
|
|
134
|
+
.option("--sessions-only", "Only import session transcripts (skip workspace files)", false)
|
|
135
|
+
.option("--session-dir <path>", "Session transcripts directory", join(homedir(), ".openclaw", "agents", "main", "sessions"))
|
|
136
|
+
.option("--group <id>", "Target group for workspace files", defGroupId)
|
|
137
|
+
.option("--dry-run", "List files without importing", false)
|
|
138
|
+
.action(async (opts) => {
|
|
139
|
+
const workspacePath = resolve(opts.workspace);
|
|
140
|
+
const targetGroup = opts.group;
|
|
141
|
+
const importSessions = opts.includeSessions || opts.sessionsOnly;
|
|
142
|
+
const importWorkspace = !opts.sessionsOnly;
|
|
143
|
+
let mdFiles = [];
|
|
144
|
+
try {
|
|
145
|
+
const entries = await readdir(workspacePath);
|
|
146
|
+
mdFiles = entries.filter((f) => f.endsWith(".md")).sort();
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
console.error(`Cannot read workspace directory: ${workspacePath}`);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
// Also check memory/ subdirectory
|
|
153
|
+
try {
|
|
154
|
+
const memDir = join(workspacePath, "memory");
|
|
155
|
+
const memEntries = await readdir(memDir);
|
|
156
|
+
for (const f of memEntries) {
|
|
157
|
+
if (f.endsWith(".md"))
|
|
158
|
+
mdFiles.push(join("memory", f));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
// No memory/ subdirectory — fine
|
|
163
|
+
}
|
|
164
|
+
if (importWorkspace) {
|
|
165
|
+
if (mdFiles.length === 0) {
|
|
166
|
+
console.log("No markdown files found in workspace.");
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
console.log(`Found ${mdFiles.length} workspace file(s) in ${workspacePath}:`);
|
|
170
|
+
for (const f of mdFiles) {
|
|
171
|
+
const filePath = join(workspacePath, f);
|
|
172
|
+
const info = await stat(filePath);
|
|
173
|
+
console.log(` ${f} (${info.size} bytes)`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (opts.dryRun) {
|
|
178
|
+
console.log("\n[dry-run] No files imported.");
|
|
179
|
+
if (importSessions) {
|
|
180
|
+
const sessionPath = resolve(opts.sessionDir);
|
|
181
|
+
try {
|
|
182
|
+
const sessions = (await readdir(sessionPath)).filter((f) => f.endsWith(".jsonl"));
|
|
183
|
+
console.log(`\nFound ${sessions.length} session transcript(s) in ${sessionPath}:`);
|
|
184
|
+
for (const f of sessions) {
|
|
185
|
+
const info = await stat(join(sessionPath, f));
|
|
186
|
+
console.log(` ${f} (${info.size} bytes)`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
console.log(`\nCannot read session directory: ${sessionPath}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const pending = [];
|
|
196
|
+
const membershipGroups = new Set();
|
|
197
|
+
// Phase 1a: Workspace files
|
|
198
|
+
if (importWorkspace && mdFiles.length > 0) {
|
|
199
|
+
if (importWorkspace)
|
|
200
|
+
membershipGroups.add(targetGroup);
|
|
201
|
+
console.log(`\nPhase 1: Importing workspace files to ${backend.name} (group: ${targetGroup})...`);
|
|
202
|
+
let imported = 0;
|
|
203
|
+
for (const f of mdFiles) {
|
|
204
|
+
const filePath = join(workspacePath, f);
|
|
205
|
+
const content = await readFile(filePath, "utf-8");
|
|
206
|
+
if (!content.trim()) {
|
|
207
|
+
console.log(` Skipping ${f} (empty)`);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
const result = await backend.store({
|
|
212
|
+
content,
|
|
213
|
+
groupId: targetGroup,
|
|
214
|
+
sourceDescription: `Imported from workspace: ${f}`,
|
|
215
|
+
});
|
|
216
|
+
pending.push({ fragmentId: result.fragmentId, groupId: targetGroup, name: f });
|
|
217
|
+
console.log(` Queued ${f} (${content.length} bytes)`);
|
|
218
|
+
imported++;
|
|
219
|
+
}
|
|
220
|
+
catch (err) {
|
|
221
|
+
console.error(` Failed to import ${f}: ${err instanceof Error ? err.message : String(err)}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
console.log(`Workspace: ${imported}/${mdFiles.length} files ingested.`);
|
|
225
|
+
}
|
|
226
|
+
// Phase 1b: Session transcripts
|
|
227
|
+
if (importSessions) {
|
|
228
|
+
const sessionPath = resolve(opts.sessionDir);
|
|
229
|
+
let jsonlFiles = [];
|
|
230
|
+
try {
|
|
231
|
+
jsonlFiles = (await readdir(sessionPath)).filter((f) => f.endsWith(".jsonl")).sort();
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
console.error(`\nCannot read session directory: ${sessionPath}`);
|
|
235
|
+
}
|
|
236
|
+
if (jsonlFiles.length === 0) {
|
|
237
|
+
console.log("\nNo session transcripts found.");
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
console.log(`\nPhase 1: Importing ${jsonlFiles.length} session transcript(s) to ${backend.name}...`);
|
|
241
|
+
let sessionsImported = 0;
|
|
242
|
+
for (const f of jsonlFiles) {
|
|
243
|
+
const sessionId = basename(f, ".jsonl");
|
|
244
|
+
const sessionGroup = sessionGroupId(sessionId);
|
|
245
|
+
const filePath = join(sessionPath, f);
|
|
246
|
+
const raw = await readFile(filePath, "utf-8");
|
|
247
|
+
const lines = raw.split("\n").filter(Boolean);
|
|
248
|
+
const conversationLines = [];
|
|
249
|
+
for (const line of lines) {
|
|
250
|
+
try {
|
|
251
|
+
const entry = JSON.parse(line);
|
|
252
|
+
const msg = (entry.type === "message" && entry.message && typeof entry.message === "object")
|
|
253
|
+
? entry.message
|
|
254
|
+
: entry;
|
|
255
|
+
const role = msg.role;
|
|
256
|
+
if (role !== "user" && role !== "assistant")
|
|
257
|
+
continue;
|
|
258
|
+
const content = msg.content;
|
|
259
|
+
let text = "";
|
|
260
|
+
if (typeof content === "string") {
|
|
261
|
+
text = content;
|
|
262
|
+
}
|
|
263
|
+
else if (Array.isArray(content)) {
|
|
264
|
+
text = content
|
|
265
|
+
.filter((b) => typeof b === "object" && b !== null &&
|
|
266
|
+
b.type === "text" &&
|
|
267
|
+
typeof b.text === "string")
|
|
268
|
+
.map((b) => b.text)
|
|
269
|
+
.join("\n");
|
|
270
|
+
}
|
|
271
|
+
if (text && text.length >= 5 &&
|
|
272
|
+
!text.includes("<relevant-memories>") &&
|
|
273
|
+
!text.includes("<memory-tools>")) {
|
|
274
|
+
const roleLabel = role === "user" ? "User" : "Assistant";
|
|
275
|
+
conversationLines.push(`${roleLabel}: ${text}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
// Skip malformed JSONL lines
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (conversationLines.length === 0) {
|
|
283
|
+
console.log(` Skipping ${f} (no user/assistant messages)`);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
const result = await backend.store({
|
|
288
|
+
content: conversationLines.join("\n"),
|
|
289
|
+
groupId: sessionGroup,
|
|
290
|
+
sourceDescription: `Imported session transcript: ${sessionId}`,
|
|
291
|
+
});
|
|
292
|
+
membershipGroups.add(sessionGroup);
|
|
293
|
+
pending.push({ fragmentId: result.fragmentId, groupId: sessionGroup, name: f });
|
|
294
|
+
console.log(` Queued ${f} (${conversationLines.length} messages) [group: ${sessionGroup}]`);
|
|
295
|
+
sessionsImported++;
|
|
296
|
+
}
|
|
297
|
+
catch (err) {
|
|
298
|
+
console.error(` Failed to import ${f}: ${err instanceof Error ? err.message : String(err)}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
console.log(`Sessions: ${sessionsImported}/${jsonlFiles.length} transcripts ingested.`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
// Phase 1.5: Await all fragmentIds concurrently
|
|
305
|
+
const pendingTuples = [];
|
|
306
|
+
if (pending.length > 0) {
|
|
307
|
+
console.log(`\nResolving ${pending.length} fragment UUIDs...`);
|
|
308
|
+
const resolutions = await Promise.allSettled(pending.map((p) => p.fragmentId));
|
|
309
|
+
for (let i = 0; i < resolutions.length; i++) {
|
|
310
|
+
const r = resolutions[i];
|
|
311
|
+
if (r.status === "fulfilled") {
|
|
312
|
+
const fragmentId = r.value;
|
|
313
|
+
pendingTuples.push({
|
|
314
|
+
resourceType: "memory_fragment",
|
|
315
|
+
resourceId: fragmentId,
|
|
316
|
+
relation: "source_group",
|
|
317
|
+
subjectType: "group",
|
|
318
|
+
subjectId: pending[i].groupId,
|
|
319
|
+
}, {
|
|
320
|
+
resourceType: "memory_fragment",
|
|
321
|
+
resourceId: fragmentId,
|
|
322
|
+
relation: "shared_by",
|
|
323
|
+
subjectType: currentSubject.type,
|
|
324
|
+
subjectId: currentSubject.id,
|
|
325
|
+
});
|
|
326
|
+
console.log(` ${pending[i].name} → ${fragmentId}`);
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
console.warn(` Warning: could not resolve UUID for ${pending[i].name} — SpiceDB linkage skipped`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
// Phase 2: Bulk write SpiceDB relationships + memberships
|
|
334
|
+
if (pendingTuples.length > 0 || membershipGroups.size > 0) {
|
|
335
|
+
for (const groupId of membershipGroups) {
|
|
336
|
+
pendingTuples.push({
|
|
337
|
+
resourceType: "group",
|
|
338
|
+
resourceId: groupId,
|
|
339
|
+
relation: "member",
|
|
340
|
+
subjectType: currentSubject.type,
|
|
341
|
+
subjectId: currentSubject.id,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
console.log(`\nPhase 2: Writing ${pendingTuples.length} SpiceDB relationships...`);
|
|
345
|
+
try {
|
|
346
|
+
const count = await spicedb.bulkImportRelationships(pendingTuples);
|
|
347
|
+
console.log(`SpiceDB: ${count} relationships written.`);
|
|
348
|
+
}
|
|
349
|
+
catch (err) {
|
|
350
|
+
console.error(`SpiceDB bulk import failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
351
|
+
console.error("Backend episodes were ingested but lack authorization. Re-run import or add relationships manually.");
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
console.log("\nImport complete.");
|
|
355
|
+
});
|
|
356
|
+
// --------------------------------------------------------------------------
|
|
357
|
+
// backfill-relationships — retroactively write per-fact SpiceDB relationships
|
|
358
|
+
// --------------------------------------------------------------------------
|
|
359
|
+
if (backend.discoverFragmentIds) {
|
|
360
|
+
cmd
|
|
361
|
+
.command("backfill-relationships")
|
|
362
|
+
.description("Retroactively write per-fact SpiceDB relationships for existing episodes")
|
|
363
|
+
.option("--group <id...>", "Group ID(s) to backfill (default: all authorized groups)")
|
|
364
|
+
.option("--last <n>", "Number of episodes per group to process", "100")
|
|
365
|
+
.option("--dry-run", "Show what would be written without writing", false)
|
|
366
|
+
.action(async (opts) => {
|
|
367
|
+
const lastN = parseInt(opts.last);
|
|
368
|
+
let groups;
|
|
369
|
+
if (opts.group && opts.group.length > 0) {
|
|
370
|
+
groups = opts.group;
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
groups = await lookupAuthorizedGroups(spicedb, currentSubject, getLastWriteToken());
|
|
374
|
+
if (groups.length === 0) {
|
|
375
|
+
console.log("No authorized groups found.");
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
console.log(`Backfilling per-fact SpiceDB relationships for ${groups.length} group(s)...`);
|
|
380
|
+
const tuples = [];
|
|
381
|
+
let totalEpisodes = 0;
|
|
382
|
+
let totalFacts = 0;
|
|
383
|
+
for (const groupId of groups) {
|
|
384
|
+
let episodes;
|
|
385
|
+
try {
|
|
386
|
+
// getEpisodes is Graphiti-specific but we already checked discoverFragmentIds
|
|
387
|
+
episodes = await backend.getEpisodes(groupId, lastN);
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
console.log(` Skipping group ${groupId} (failed to list episodes)`);
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
console.log(` Group "${groupId}": ${episodes.length} episode(s)`);
|
|
394
|
+
for (const ep of episodes) {
|
|
395
|
+
const factIds = await backend.discoverFragmentIds(ep.uuid);
|
|
396
|
+
if (factIds.length === 0)
|
|
397
|
+
continue;
|
|
398
|
+
totalEpisodes++;
|
|
399
|
+
totalFacts += factIds.length;
|
|
400
|
+
for (const factId of factIds) {
|
|
401
|
+
tuples.push({
|
|
402
|
+
resourceType: "memory_fragment",
|
|
403
|
+
resourceId: factId,
|
|
404
|
+
relation: "source_group",
|
|
405
|
+
subjectType: "group",
|
|
406
|
+
subjectId: groupId,
|
|
407
|
+
}, {
|
|
408
|
+
resourceType: "memory_fragment",
|
|
409
|
+
resourceId: factId,
|
|
410
|
+
relation: "shared_by",
|
|
411
|
+
subjectType: currentSubject.type,
|
|
412
|
+
subjectId: currentSubject.id,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
if (!opts.dryRun && factIds.length > 0) {
|
|
416
|
+
process.stdout.write(".");
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (!opts.dryRun && totalFacts > 0) {
|
|
421
|
+
console.log(); // newline after dots
|
|
422
|
+
}
|
|
423
|
+
console.log(`\nDiscovered ${totalFacts} fact(s) across ${totalEpisodes} episode(s).`);
|
|
424
|
+
if (opts.dryRun) {
|
|
425
|
+
console.log(`[dry-run] Would write ${tuples.length} SpiceDB relationships.`);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (tuples.length === 0) {
|
|
429
|
+
console.log("No relationships to write.");
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
console.log(`Writing ${tuples.length} SpiceDB relationships...`);
|
|
433
|
+
try {
|
|
434
|
+
const count = await spicedb.bulkImportRelationships(tuples);
|
|
435
|
+
console.log(`Done: ${count} relationships written.`);
|
|
436
|
+
}
|
|
437
|
+
catch (err) {
|
|
438
|
+
console.error(`SpiceDB bulk import failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
// --------------------------------------------------------------------------
|
|
443
|
+
// Backend-specific extension point
|
|
444
|
+
// --------------------------------------------------------------------------
|
|
445
|
+
backend.registerCliCommands?.(cmd);
|
|
446
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified configuration for openclaw-memory-rebac.
|
|
3
|
+
*
|
|
4
|
+
* Backend-specific defaults live in backends/<name>.defaults.json.
|
|
5
|
+
* Available backends are listed in backends/backends.json.
|
|
6
|
+
* No backend names appear in this file.
|
|
7
|
+
*/
|
|
8
|
+
import type { MemoryBackend } from "./backend.js";
|
|
9
|
+
export type RebacMemoryConfig = {
|
|
10
|
+
backend: string;
|
|
11
|
+
spicedb: {
|
|
12
|
+
endpoint: string;
|
|
13
|
+
token: string;
|
|
14
|
+
insecure: boolean;
|
|
15
|
+
};
|
|
16
|
+
backendConfig: Record<string, unknown>;
|
|
17
|
+
subjectType: "agent" | "person";
|
|
18
|
+
subjectId: string;
|
|
19
|
+
autoCapture: boolean;
|
|
20
|
+
autoRecall: boolean;
|
|
21
|
+
maxCaptureMessages: number;
|
|
22
|
+
};
|
|
23
|
+
export declare const rebacMemoryConfigSchema: {
|
|
24
|
+
parse(value: unknown): RebacMemoryConfig;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Instantiate the configured MemoryBackend from the parsed config.
|
|
28
|
+
* Call this once during plugin registration — the returned backend is stateful.
|
|
29
|
+
*/
|
|
30
|
+
export declare function createBackend(cfg: RebacMemoryConfig): MemoryBackend;
|
|
31
|
+
/**
|
|
32
|
+
* Return the default group ID for the active backend.
|
|
33
|
+
*/
|
|
34
|
+
export declare function defaultGroupId(cfg: RebacMemoryConfig): string;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified configuration for openclaw-memory-rebac.
|
|
3
|
+
*
|
|
4
|
+
* Backend-specific defaults live in backends/<name>.defaults.json.
|
|
5
|
+
* Available backends are listed in backends/backends.json.
|
|
6
|
+
* No backend names appear in this file.
|
|
7
|
+
*/
|
|
8
|
+
import { backendRegistry } from "./backends/registry.js";
|
|
9
|
+
import pluginDefaults from "./plugin.defaults.json" with { type: "json" };
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// Helpers
|
|
12
|
+
// ============================================================================
|
|
13
|
+
function resolveEnvVars(value) {
|
|
14
|
+
return value.replace(/\$\{([^}]+)\}/g, (_, envVar) => {
|
|
15
|
+
const envValue = process.env[envVar];
|
|
16
|
+
if (!envValue) {
|
|
17
|
+
throw new Error(`Environment variable ${envVar} is not set`);
|
|
18
|
+
}
|
|
19
|
+
return envValue;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
function assertAllowedKeys(value, allowed, label) {
|
|
23
|
+
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
24
|
+
if (unknown.length > 0) {
|
|
25
|
+
throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Config schema
|
|
30
|
+
// ============================================================================
|
|
31
|
+
export const rebacMemoryConfigSchema = {
|
|
32
|
+
parse(value) {
|
|
33
|
+
if (Array.isArray(value)) {
|
|
34
|
+
throw new Error("openclaw-memory-rebac config must be an object, not an array");
|
|
35
|
+
}
|
|
36
|
+
const cfg = (value && typeof value === "object" ? value : {});
|
|
37
|
+
const backendName = typeof cfg.backend === "string" ? cfg.backend : pluginDefaults.backend;
|
|
38
|
+
const entry = backendRegistry[backendName];
|
|
39
|
+
if (!entry)
|
|
40
|
+
throw new Error(`Unknown backend: "${backendName}"`);
|
|
41
|
+
// Top-level allowed keys: shared keys + the backend name key
|
|
42
|
+
assertAllowedKeys(cfg, [
|
|
43
|
+
"backend", "spicedb",
|
|
44
|
+
"subjectType", "subjectId",
|
|
45
|
+
"autoCapture", "autoRecall", "maxCaptureMessages",
|
|
46
|
+
backendName,
|
|
47
|
+
], "openclaw-memory-rebac config");
|
|
48
|
+
// SpiceDB config (shared)
|
|
49
|
+
const spicedb = cfg.spicedb ?? {};
|
|
50
|
+
assertAllowedKeys(spicedb, ["endpoint", "token", "insecure"], "spicedb config");
|
|
51
|
+
// Backend config: user overrides merged over JSON defaults
|
|
52
|
+
const backendRaw = cfg[backendName] ?? {};
|
|
53
|
+
assertAllowedKeys(backendRaw, Object.keys(entry.defaults), `${backendName} config`);
|
|
54
|
+
const backendConfig = { ...entry.defaults, ...backendRaw };
|
|
55
|
+
const subjectType = cfg.subjectType === "person" ? "person" : pluginDefaults.subjectType;
|
|
56
|
+
const subjectId = typeof cfg.subjectId === "string" ? resolveEnvVars(cfg.subjectId) : pluginDefaults.subjectId;
|
|
57
|
+
return {
|
|
58
|
+
backend: backendName,
|
|
59
|
+
spicedb: {
|
|
60
|
+
endpoint: typeof spicedb.endpoint === "string"
|
|
61
|
+
? spicedb.endpoint
|
|
62
|
+
: pluginDefaults.spicedb.endpoint,
|
|
63
|
+
token: typeof spicedb.token === "string" ? resolveEnvVars(spicedb.token) : "",
|
|
64
|
+
insecure: typeof spicedb.insecure === "boolean"
|
|
65
|
+
? spicedb.insecure
|
|
66
|
+
: pluginDefaults.spicedb.insecure,
|
|
67
|
+
},
|
|
68
|
+
backendConfig,
|
|
69
|
+
subjectType,
|
|
70
|
+
subjectId,
|
|
71
|
+
autoCapture: cfg.autoCapture !== false,
|
|
72
|
+
autoRecall: cfg.autoRecall !== false,
|
|
73
|
+
maxCaptureMessages: typeof cfg.maxCaptureMessages === "number" && cfg.maxCaptureMessages > 0
|
|
74
|
+
? cfg.maxCaptureMessages
|
|
75
|
+
: pluginDefaults.maxCaptureMessages,
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
// ============================================================================
|
|
80
|
+
// Backend factory
|
|
81
|
+
// ============================================================================
|
|
82
|
+
/**
|
|
83
|
+
* Instantiate the configured MemoryBackend from the parsed config.
|
|
84
|
+
* Call this once during plugin registration — the returned backend is stateful.
|
|
85
|
+
*/
|
|
86
|
+
export function createBackend(cfg) {
|
|
87
|
+
const entry = backendRegistry[cfg.backend];
|
|
88
|
+
if (!entry)
|
|
89
|
+
throw new Error(`Unknown backend: "${cfg.backend}"`);
|
|
90
|
+
return entry.create(cfg.backendConfig);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Return the default group ID for the active backend.
|
|
94
|
+
*/
|
|
95
|
+
export function defaultGroupId(cfg) {
|
|
96
|
+
return cfg.backendConfig["defaultGroupId"] ?? "main";
|
|
97
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenClaw Memory (ReBAC) Plugin
|
|
3
|
+
*
|
|
4
|
+
* Two-layer memory architecture:
|
|
5
|
+
* - SpiceDB: authorization gateway (who can see what)
|
|
6
|
+
* - MemoryBackend: pluggable storage engine (Graphiti)
|
|
7
|
+
*
|
|
8
|
+
* SpiceDB determines which memories a subject can access.
|
|
9
|
+
* The backend stores the actual knowledge and handles search.
|
|
10
|
+
* Authorization is enforced at the data layer, not in prompts.
|
|
11
|
+
*
|
|
12
|
+
* Backend currently uses Graphiti for knowledge graph storage.
|
|
13
|
+
*/
|
|
14
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
15
|
+
declare const rebacMemoryPlugin: {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
description: string;
|
|
19
|
+
kind: "memory";
|
|
20
|
+
configSchema: {
|
|
21
|
+
parse(value: unknown): import("./config.js").RebacMemoryConfig;
|
|
22
|
+
};
|
|
23
|
+
register(api: OpenClawPluginApi): void;
|
|
24
|
+
};
|
|
25
|
+
export default rebacMemoryPlugin;
|