@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/index.js ADDED
@@ -0,0 +1,638 @@
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 } from "@sinclair/typebox";
15
+ import { readFileSync } from "node:fs";
16
+ import { join, dirname } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { rebacMemoryConfigSchema, createBackend, defaultGroupId } from "./config.js";
19
+ import { SpiceDbClient } from "./spicedb.js";
20
+ import { lookupAuthorizedGroups, writeFragmentRelationships, deleteFragmentRelationships, canDeleteFragment, canWriteToGroup, ensureGroupMembership, } from "./authorization.js";
21
+ import { searchAuthorizedMemories, formatDualResults, deduplicateSessionResults, } from "./search.js";
22
+ import { registerCommands } from "./cli.js";
23
+ // ============================================================================
24
+ // Session helpers
25
+ // ============================================================================
26
+ function sessionGroupId(sessionId) {
27
+ const sanitized = sessionId.replace(/[^a-zA-Z0-9_-]/g, "-");
28
+ return `session-${sanitized}`;
29
+ }
30
+ function isSessionGroup(groupId) {
31
+ return groupId.startsWith("session-");
32
+ }
33
+ // ============================================================================
34
+ // Plugin Definition
35
+ // ============================================================================
36
+ const rebacMemoryPlugin = {
37
+ id: "openclaw-memory-rebac",
38
+ name: "Memory (ReBAC)",
39
+ description: "Two-layer memory: SpiceDB authorization + Graphiti knowledge graph",
40
+ kind: "memory",
41
+ configSchema: rebacMemoryConfigSchema,
42
+ register(api) {
43
+ const cfg = rebacMemoryConfigSchema.parse(api.pluginConfig);
44
+ if (!cfg.spicedb.token) {
45
+ throw new Error('openclaw-memory-rebac: spicedb.token is not configured. Add a "config" block to ' +
46
+ "plugins.entries.openclaw-memory-rebac in ~/.openclaw/openclaw.json:\n" +
47
+ ' "config": { "spicedb": { "token": "<your-preshared-key>", "insecure": true } }');
48
+ }
49
+ const backend = createBackend(cfg);
50
+ const spicedb = new SpiceDbClient(cfg.spicedb);
51
+ const backendDefaultGroupId = defaultGroupId(cfg);
52
+ // Suppress transient gRPC rejections from @grpc/grpc-js during connection setup
53
+ const grpcRejectionHandler = (reason) => {
54
+ const msg = String(reason);
55
+ if (msg.includes("generateMetadata") || msg.includes("grpc")) {
56
+ api.logger.warn(`openclaw-memory-rebac: suppressed grpc-js rejection: ${msg}`);
57
+ }
58
+ else {
59
+ throw reason;
60
+ }
61
+ };
62
+ process.on("unhandledRejection", grpcRejectionHandler);
63
+ const grpcGuardTimer = setTimeout(() => {
64
+ process.removeListener("unhandledRejection", grpcRejectionHandler);
65
+ }, 10_000);
66
+ grpcGuardTimer.unref();
67
+ const currentSubject = { type: cfg.subjectType, id: cfg.subjectId };
68
+ let currentSessionId;
69
+ let lastWriteToken;
70
+ api.logger.info(`openclaw-memory-rebac: registered (backend: ${backend.name}, spicedb: ${cfg.spicedb.endpoint})`);
71
+ // ========================================================================
72
+ // Tools
73
+ // ========================================================================
74
+ api.registerTool({
75
+ name: "memory_recall",
76
+ label: "Memory Recall",
77
+ description: "Search through memories using the knowledge graph. Returns results the current user is authorized to see. Supports session, long-term, or combined scope. REQUIRES a search query.",
78
+ parameters: Type.Object({
79
+ query: Type.String({ description: "REQUIRED: Search query for semantic matching" }),
80
+ limit: Type.Optional(Type.Number({ description: "Max results (default: 10)" })),
81
+ scope: Type.Optional(Type.Union([Type.Literal("session"), Type.Literal("long-term"), Type.Literal("all")], { description: "Memory scope: 'session', 'long-term', or 'all' (default)" })),
82
+ }),
83
+ async execute(_toolCallId, params) {
84
+ const { query, limit = 10, scope = "all" } = params;
85
+ const authorizedGroups = await lookupAuthorizedGroups(spicedb, currentSubject, lastWriteToken);
86
+ if (authorizedGroups.length === 0) {
87
+ return {
88
+ content: [{ type: "text", text: "No accessible memory groups found." }],
89
+ details: { count: 0, authorizedGroups: [] },
90
+ };
91
+ }
92
+ let longTermGroups;
93
+ let sessionGroups;
94
+ if (scope === "session") {
95
+ longTermGroups = [];
96
+ sessionGroups = authorizedGroups.filter(isSessionGroup);
97
+ if (currentSessionId) {
98
+ const sg = sessionGroupId(currentSessionId);
99
+ if (!sessionGroups.includes(sg))
100
+ sessionGroups.push(sg);
101
+ }
102
+ }
103
+ else if (scope === "long-term") {
104
+ longTermGroups = authorizedGroups.filter((g) => !isSessionGroup(g));
105
+ sessionGroups = [];
106
+ }
107
+ else {
108
+ longTermGroups = authorizedGroups.filter((g) => !isSessionGroup(g));
109
+ sessionGroups = authorizedGroups.filter(isSessionGroup);
110
+ if (currentSessionId) {
111
+ const sg = sessionGroupId(currentSessionId);
112
+ if (!sessionGroups.includes(sg))
113
+ sessionGroups.push(sg);
114
+ }
115
+ }
116
+ const [longTermResults, rawSessionResults] = await Promise.all([
117
+ longTermGroups.length > 0
118
+ ? searchAuthorizedMemories(backend, {
119
+ query,
120
+ groupIds: longTermGroups,
121
+ limit,
122
+ sessionId: currentSessionId,
123
+ })
124
+ : Promise.resolve([]),
125
+ sessionGroups.length > 0
126
+ ? searchAuthorizedMemories(backend, {
127
+ query,
128
+ groupIds: sessionGroups,
129
+ limit,
130
+ sessionId: currentSessionId,
131
+ })
132
+ : Promise.resolve([]),
133
+ ]);
134
+ const sessionResults = deduplicateSessionResults(longTermResults, rawSessionResults);
135
+ const totalCount = longTermResults.length + sessionResults.length;
136
+ if (totalCount === 0) {
137
+ return {
138
+ content: [{ type: "text", text: "No relevant memories found." }],
139
+ details: { count: 0, authorizedGroups },
140
+ };
141
+ }
142
+ const text = formatDualResults(longTermResults, sessionResults);
143
+ const allResults = [...longTermResults, ...sessionResults];
144
+ const sanitized = allResults.map((r) => ({
145
+ type: r.type,
146
+ uuid: r.uuid,
147
+ group_id: r.group_id,
148
+ summary: r.summary,
149
+ context: r.context,
150
+ }));
151
+ return {
152
+ content: [{ type: "text", text: `Found ${totalCount} memories:\n\n${text}` }],
153
+ details: {
154
+ count: totalCount,
155
+ memories: sanitized,
156
+ authorizedGroups,
157
+ longTermCount: longTermResults.length,
158
+ sessionCount: sessionResults.length,
159
+ },
160
+ };
161
+ },
162
+ }, { name: "memory_recall" });
163
+ api.registerTool({
164
+ name: "memory_store",
165
+ label: "Memory Store",
166
+ description: "Save information to the knowledge graph with authorization tracking. Use longTerm=false to store session-scoped memories.",
167
+ parameters: Type.Object({
168
+ content: Type.String({ description: "Information to remember" }),
169
+ source_description: Type.Optional(Type.String({ description: "Context about the source (e.g., 'conversation with Mark')" })),
170
+ involves: Type.Optional(Type.Array(Type.String(), { description: "Person/agent IDs involved in this memory" })),
171
+ group_id: Type.Optional(Type.String({ description: "Target group ID (uses default group if omitted)" })),
172
+ longTerm: Type.Optional(Type.Boolean({ description: "Store as long-term memory (default: true). Set to false for session-scoped." })),
173
+ }),
174
+ async execute(_toolCallId, params) {
175
+ const { content, source_description = "conversation", involves = [], group_id, longTerm = true, } = params;
176
+ const sanitizeGroupId = (id) => {
177
+ if (!id)
178
+ return undefined;
179
+ const trimmed = id.trim();
180
+ if (trimmed.includes(" ") || trimmed.toLowerCase().includes("configured")) {
181
+ return undefined;
182
+ }
183
+ return trimmed;
184
+ };
185
+ let targetGroupId;
186
+ const sanitizedGroupId = sanitizeGroupId(group_id);
187
+ if (sanitizedGroupId) {
188
+ targetGroupId = sanitizedGroupId;
189
+ }
190
+ else if (!longTerm && currentSessionId) {
191
+ targetGroupId = sessionGroupId(currentSessionId);
192
+ }
193
+ else {
194
+ targetGroupId = backendDefaultGroupId;
195
+ }
196
+ const isOwnSession = isSessionGroup(targetGroupId) &&
197
+ currentSessionId != null &&
198
+ targetGroupId === sessionGroupId(currentSessionId);
199
+ if (isOwnSession) {
200
+ try {
201
+ const token = await ensureGroupMembership(spicedb, targetGroupId, currentSubject);
202
+ if (token)
203
+ lastWriteToken = token;
204
+ }
205
+ catch {
206
+ api.logger.warn(`openclaw-memory-rebac: failed to ensure membership in ${targetGroupId}`);
207
+ }
208
+ }
209
+ else {
210
+ const allowed = await canWriteToGroup(spicedb, currentSubject, targetGroupId, lastWriteToken);
211
+ if (!allowed) {
212
+ return {
213
+ content: [{ type: "text", text: `Permission denied: cannot write to group "${targetGroupId}"` }],
214
+ details: { action: "denied", groupId: targetGroupId },
215
+ };
216
+ }
217
+ }
218
+ const involvedSubjects = involves.map((id) => ({ type: "person", id }));
219
+ const result = await backend.store({
220
+ content,
221
+ groupId: targetGroupId,
222
+ sourceDescription: source_description,
223
+ customPrompt: cfg.backendConfig["customInstructions"] ?? "",
224
+ });
225
+ // Chain SpiceDB writes to the fragmentId Promise — fires when backend
226
+ // has a stable episode UUID. Discover extracted fact UUIDs and write
227
+ // per-fact relationships so that fragment-level permissions (view, delete)
228
+ // resolve correctly against the IDs returned by memory_recall.
229
+ result.fragmentId
230
+ .then(async (episodeId) => {
231
+ const factIds = backend.discoverFragmentIds
232
+ ? await backend.discoverFragmentIds(episodeId)
233
+ : [];
234
+ if (factIds.length > 0) {
235
+ for (const factId of factIds) {
236
+ const writeToken = await writeFragmentRelationships(spicedb, {
237
+ fragmentId: factId,
238
+ groupId: targetGroupId,
239
+ sharedBy: currentSubject,
240
+ involves: involvedSubjects,
241
+ });
242
+ if (writeToken)
243
+ lastWriteToken = writeToken;
244
+ }
245
+ api.logger.info(`openclaw-memory-rebac: wrote SpiceDB relationships for ${factIds.length} fact(s) from episode ${episodeId}`);
246
+ }
247
+ else {
248
+ // Fallback: write relationships for the episode UUID itself
249
+ const writeToken = await writeFragmentRelationships(spicedb, {
250
+ fragmentId: episodeId,
251
+ groupId: targetGroupId,
252
+ sharedBy: currentSubject,
253
+ involves: involvedSubjects,
254
+ });
255
+ if (writeToken)
256
+ lastWriteToken = writeToken;
257
+ }
258
+ })
259
+ .catch((err) => {
260
+ api.logger.warn(`openclaw-memory-rebac: deferred SpiceDB write failed for memory_store: ${err}`);
261
+ });
262
+ return {
263
+ content: [{ type: "text", text: `Stored memory in group "${targetGroupId}": "${content.slice(0, 100)}..."` }],
264
+ details: {
265
+ action: "created",
266
+ groupId: targetGroupId,
267
+ backend: backend.name,
268
+ longTerm,
269
+ involves,
270
+ },
271
+ };
272
+ },
273
+ }, { name: "memory_store" });
274
+ api.registerTool({
275
+ name: "memory_forget",
276
+ label: "Memory Forget",
277
+ description: "Remove a memory fragment by ID. Use type-prefixed IDs from memory_recall (e.g. 'fact:UUID', 'chunk:UUID'). Always de-authorizes from SpiceDB; also deletes from storage if the backend supports individual deletion.",
278
+ parameters: Type.Object({
279
+ id: Type.String({ description: "Memory ID to forget (e.g. 'fact:da8650cb-...' or bare UUID)" }),
280
+ }),
281
+ async execute(_toolCallId, params) {
282
+ const { id } = params;
283
+ // Parse optional type prefix — strip it to get the bare UUID
284
+ const colonIdx = id.indexOf(":");
285
+ let uuid;
286
+ if (colonIdx > 0 && colonIdx < 10) {
287
+ const prefix = id.slice(0, colonIdx);
288
+ // "entity" type cannot be deleted this way (graph-backend specific)
289
+ if (prefix === "entity") {
290
+ return {
291
+ content: [{ type: "text", text: "Entities cannot be deleted directly. Delete the facts connected to them instead." }],
292
+ details: { action: "error", id },
293
+ };
294
+ }
295
+ uuid = id.slice(colonIdx + 1);
296
+ }
297
+ else {
298
+ uuid = id;
299
+ }
300
+ // Check SpiceDB delete permission.
301
+ // Fragment-level relationships may be missing (episode UUID vs fact UUID mismatch),
302
+ // so fall back to group-level authorization: if the subject can contribute to any
303
+ // group they have access to, allow deletion.
304
+ let allowed = await canDeleteFragment(spicedb, currentSubject, uuid, lastWriteToken);
305
+ if (!allowed) {
306
+ const groups = await lookupAuthorizedGroups(spicedb, currentSubject, lastWriteToken);
307
+ for (const g of groups) {
308
+ if (await canWriteToGroup(spicedb, currentSubject, g, lastWriteToken)) {
309
+ allowed = true;
310
+ break;
311
+ }
312
+ }
313
+ if (allowed) {
314
+ api.logger.info(`openclaw-memory-rebac: fragment-level delete check failed for "${uuid}", authorized via group membership`);
315
+ }
316
+ }
317
+ if (!allowed) {
318
+ return {
319
+ content: [{ type: "text", text: `Permission denied: cannot delete fragment "${uuid}"` }],
320
+ details: { action: "denied", id },
321
+ };
322
+ }
323
+ // Attempt backend deletion (optional — not all backends support it)
324
+ if (backend.deleteFragment) {
325
+ try {
326
+ await backend.deleteFragment(uuid);
327
+ }
328
+ catch (err) {
329
+ api.logger.warn(`openclaw-memory-rebac: backend deletion failed for ${uuid}: ${err}`);
330
+ // Continue to SpiceDB de-authorization even if backend deletion fails
331
+ }
332
+ }
333
+ // Always de-authorize in SpiceDB
334
+ const writeToken = await deleteFragmentRelationships(spicedb, uuid);
335
+ if (writeToken)
336
+ lastWriteToken = writeToken;
337
+ return {
338
+ content: [{ type: "text", text: "Memory forgotten." }],
339
+ details: { action: "deleted", id, uuid },
340
+ };
341
+ },
342
+ }, { name: "memory_forget" });
343
+ api.registerTool({
344
+ name: "memory_status",
345
+ label: "Memory Status",
346
+ description: "Check the health of the memory backend and SpiceDB.",
347
+ parameters: Type.Object({}),
348
+ async execute() {
349
+ const backendStatus = await backend.getStatus();
350
+ let spicedbOk = false;
351
+ try {
352
+ await spicedb.readSchema();
353
+ spicedbOk = true;
354
+ }
355
+ catch {
356
+ // SpiceDB unreachable
357
+ }
358
+ const status = {
359
+ ...backendStatus,
360
+ spicedb: spicedbOk ? "connected" : "unreachable",
361
+ endpoint_spicedb: cfg.spicedb.endpoint,
362
+ currentSessionId: currentSessionId ?? "none",
363
+ };
364
+ const statusText = [
365
+ `Backend (${backend.name}): ${backendStatus.healthy ? "connected" : "unreachable"}`,
366
+ `SpiceDB: ${spicedbOk ? "connected" : "unreachable"} (${cfg.spicedb.endpoint})`,
367
+ `Session: ${currentSessionId ?? "none"}`,
368
+ ].join("\n");
369
+ return {
370
+ content: [{ type: "text", text: statusText }],
371
+ details: status,
372
+ };
373
+ },
374
+ }, { name: "memory_status" });
375
+ // ========================================================================
376
+ // CLI Commands
377
+ // ========================================================================
378
+ api.registerCli(({ program }) => {
379
+ const mem = program
380
+ .command("rebac-mem")
381
+ .description(`ReBAC memory plugin commands (backend: ${backend.name})`);
382
+ registerCommands(mem, {
383
+ backend,
384
+ spicedb,
385
+ cfg,
386
+ currentSubject,
387
+ getLastWriteToken: () => lastWriteToken,
388
+ });
389
+ }, { commands: ["rebac-mem"] });
390
+ // ========================================================================
391
+ // Lifecycle Hooks
392
+ // ========================================================================
393
+ if (cfg.autoRecall) {
394
+ api.on("before_agent_start", async (event, ctx) => {
395
+ if (ctx?.sessionKey)
396
+ currentSessionId = ctx.sessionKey;
397
+ if (!event.prompt || event.prompt.length < 5)
398
+ return;
399
+ try {
400
+ const authorizedGroups = await lookupAuthorizedGroups(spicedb, currentSubject, lastWriteToken);
401
+ if (authorizedGroups.length === 0)
402
+ return;
403
+ const longTermGroups = authorizedGroups.filter((g) => !isSessionGroup(g));
404
+ const sessionGroups = authorizedGroups.filter(isSessionGroup);
405
+ if (currentSessionId) {
406
+ const sg = sessionGroupId(currentSessionId);
407
+ if (!sessionGroups.includes(sg))
408
+ sessionGroups.push(sg);
409
+ }
410
+ const [longTermResults, rawSessionResults] = await Promise.all([
411
+ longTermGroups.length > 0
412
+ ? searchAuthorizedMemories(backend, {
413
+ query: event.prompt,
414
+ groupIds: longTermGroups,
415
+ limit: 5,
416
+ sessionId: currentSessionId,
417
+ })
418
+ : Promise.resolve([]),
419
+ sessionGroups.length > 0
420
+ ? searchAuthorizedMemories(backend, {
421
+ query: event.prompt,
422
+ groupIds: sessionGroups,
423
+ limit: 3,
424
+ sessionId: currentSessionId,
425
+ })
426
+ : Promise.resolve([]),
427
+ ]);
428
+ const sessionResults = deduplicateSessionResults(longTermResults, rawSessionResults);
429
+ const totalCount = longTermResults.length + sessionResults.length;
430
+ const toolHint = "<memory-tools>\n" +
431
+ "You have knowledge-graph memory tools. Use them proactively:\n" +
432
+ "- memory_recall: Search for facts, preferences, people, decisions, or past context. Use this BEFORE saying you don't know or remember something.\n" +
433
+ "- memory_store: Save important new information (preferences, decisions, facts about people).\n" +
434
+ "</memory-tools>";
435
+ if (totalCount === 0)
436
+ return { prependContext: toolHint };
437
+ const memoryContext = formatDualResults(longTermResults, sessionResults);
438
+ api.logger.info?.(`openclaw-memory-rebac: injecting ${totalCount} memories (${longTermResults.length} long-term, ${sessionResults.length} session)`);
439
+ return {
440
+ prependContext: `${toolHint}\n\n<relevant-memories>\nThe following memories may be relevant:\n${memoryContext}\n</relevant-memories>`,
441
+ };
442
+ }
443
+ catch (err) {
444
+ api.logger.warn(`openclaw-memory-rebac: recall failed: ${String(err)}`);
445
+ }
446
+ });
447
+ }
448
+ if (cfg.autoCapture) {
449
+ api.on("agent_end", async (event, ctx) => {
450
+ if (ctx?.sessionKey)
451
+ currentSessionId = ctx.sessionKey;
452
+ if (!event.success || !event.messages || event.messages.length === 0)
453
+ return;
454
+ try {
455
+ const maxMessages = cfg.maxCaptureMessages;
456
+ const conversationLines = [];
457
+ let messageCount = 0;
458
+ for (const msg of [...event.messages].reverse()) {
459
+ if (messageCount >= maxMessages)
460
+ break;
461
+ if (!msg || typeof msg !== "object")
462
+ continue;
463
+ const msgObj = msg;
464
+ const role = msgObj.role;
465
+ if (role !== "user" && role !== "assistant")
466
+ continue;
467
+ let text = "";
468
+ const content = msgObj.content;
469
+ if (typeof content === "string") {
470
+ text = content;
471
+ }
472
+ else if (Array.isArray(content)) {
473
+ const textParts = [];
474
+ for (const block of content) {
475
+ if (block && typeof block === "object" &&
476
+ "type" in block &&
477
+ block.type === "text" &&
478
+ "text" in block &&
479
+ typeof block.text === "string") {
480
+ textParts.push(block.text);
481
+ }
482
+ }
483
+ text = textParts.join("\n");
484
+ }
485
+ if (!text || text.length < 5)
486
+ continue;
487
+ if (text.includes("<relevant-memories>"))
488
+ continue;
489
+ const roleLabel = role === "user" ? "User" : "Assistant";
490
+ conversationLines.unshift(`${roleLabel}: ${text}`);
491
+ messageCount++;
492
+ }
493
+ if (conversationLines.length === 0)
494
+ return;
495
+ const episodeBody = conversationLines.join("\n");
496
+ const targetGroupId = currentSessionId
497
+ ? sessionGroupId(currentSessionId)
498
+ : backendDefaultGroupId;
499
+ const isOwnSession = isSessionGroup(targetGroupId) &&
500
+ currentSessionId != null &&
501
+ targetGroupId === sessionGroupId(currentSessionId);
502
+ if (isOwnSession) {
503
+ try {
504
+ const token = await ensureGroupMembership(spicedb, targetGroupId, currentSubject);
505
+ if (token)
506
+ lastWriteToken = token;
507
+ }
508
+ catch {
509
+ // best-effort
510
+ }
511
+ }
512
+ else {
513
+ const allowed = await canWriteToGroup(spicedb, currentSubject, targetGroupId, lastWriteToken);
514
+ if (!allowed) {
515
+ api.logger.warn(`openclaw-memory-rebac: auto-capture denied for group ${targetGroupId}`);
516
+ return;
517
+ }
518
+ }
519
+ const result = await backend.store({
520
+ content: episodeBody,
521
+ groupId: targetGroupId,
522
+ sourceDescription: "auto-captured conversation",
523
+ customPrompt: cfg.backendConfig["customInstructions"] ?? "",
524
+ });
525
+ // Chain SpiceDB writes — discover per-fact UUIDs when possible
526
+ result.fragmentId
527
+ .then(async (episodeId) => {
528
+ const factIds = backend.discoverFragmentIds
529
+ ? await backend.discoverFragmentIds(episodeId)
530
+ : [];
531
+ if (factIds.length > 0) {
532
+ for (const factId of factIds) {
533
+ const writeToken = await writeFragmentRelationships(spicedb, {
534
+ fragmentId: factId,
535
+ groupId: targetGroupId,
536
+ sharedBy: currentSubject,
537
+ });
538
+ if (writeToken)
539
+ lastWriteToken = writeToken;
540
+ }
541
+ }
542
+ else {
543
+ const writeToken = await writeFragmentRelationships(spicedb, {
544
+ fragmentId: episodeId,
545
+ groupId: targetGroupId,
546
+ sharedBy: currentSubject,
547
+ });
548
+ if (writeToken)
549
+ lastWriteToken = writeToken;
550
+ }
551
+ })
552
+ .catch((err) => {
553
+ api.logger.warn(`openclaw-memory-rebac: deferred SpiceDB write (auto-capture) failed: ${err}`);
554
+ });
555
+ // Backend-specific session enrichment (optional backend feature)
556
+ if (backend.enrichSession && currentSessionId) {
557
+ const lastUserMsg = [...event.messages]
558
+ .reverse()
559
+ .find((m) => m.role === "user");
560
+ const lastAssistMsg = [...event.messages]
561
+ .reverse()
562
+ .find((m) => m.role === "assistant");
563
+ const extractText = (m) => {
564
+ if (!m || typeof m !== "object")
565
+ return "";
566
+ const obj = m;
567
+ if (typeof obj.content === "string")
568
+ return obj.content;
569
+ if (Array.isArray(obj.content)) {
570
+ return obj.content
571
+ .filter((b) => typeof b === "object" && b !== null &&
572
+ b.type === "text")
573
+ .map((b) => b.text)
574
+ .join("\n");
575
+ }
576
+ return "";
577
+ };
578
+ const userMsg = extractText(lastUserMsg);
579
+ const assistantMsg = extractText(lastAssistMsg);
580
+ if (userMsg && assistantMsg) {
581
+ backend.enrichSession({
582
+ sessionId: currentSessionId,
583
+ groupId: targetGroupId,
584
+ userMsg,
585
+ assistantMsg,
586
+ }).catch(() => { });
587
+ }
588
+ }
589
+ api.logger.info(`openclaw-memory-rebac: auto-captured ${conversationLines.length} messages to ${targetGroupId}`);
590
+ }
591
+ catch (err) {
592
+ api.logger.warn(`openclaw-memory-rebac: capture failed: ${String(err)}`);
593
+ }
594
+ });
595
+ }
596
+ // ========================================================================
597
+ // Service
598
+ // ========================================================================
599
+ api.registerService({
600
+ id: "openclaw-memory-rebac",
601
+ async start() {
602
+ const backendStatus = await backend.getStatus();
603
+ let spicedbOk = false;
604
+ try {
605
+ const existing = await spicedb.readSchema();
606
+ spicedbOk = true;
607
+ if (!existing || !existing.includes("memory_fragment")) {
608
+ api.logger.info("openclaw-memory-rebac: writing SpiceDB schema (first run)");
609
+ const schemaPath = join(dirname(fileURLToPath(import.meta.url)), "schema.zed");
610
+ const schema = readFileSync(schemaPath, "utf-8");
611
+ await spicedb.writeSchema(schema);
612
+ api.logger.info("openclaw-memory-rebac: SpiceDB schema written successfully");
613
+ }
614
+ }
615
+ catch {
616
+ // Will be retried on first use
617
+ }
618
+ if (spicedbOk) {
619
+ try {
620
+ const token = await ensureGroupMembership(spicedb, backendDefaultGroupId, currentSubject);
621
+ if (token)
622
+ lastWriteToken = token;
623
+ }
624
+ catch {
625
+ api.logger.warn("openclaw-memory-rebac: failed to ensure default group membership");
626
+ }
627
+ }
628
+ api.logger.info(`openclaw-memory-rebac: initialized (backend: ${backend.name} ${backendStatus.healthy ? "OK" : "UNREACHABLE"}, spicedb: ${spicedbOk ? "OK" : "UNREACHABLE"})`);
629
+ },
630
+ stop() {
631
+ clearTimeout(grpcGuardTimer);
632
+ process.removeListener("unhandledRejection", grpcRejectionHandler);
633
+ api.logger.info("openclaw-memory-rebac: stopped");
634
+ },
635
+ });
636
+ },
637
+ };
638
+ export default rebacMemoryPlugin;
@@ -0,0 +1,12 @@
1
+ {
2
+ "backend": "graphiti",
3
+ "spicedb": {
4
+ "endpoint": "localhost:50051",
5
+ "insecure": true
6
+ },
7
+ "subjectType": "agent",
8
+ "subjectId": "default",
9
+ "autoCapture": true,
10
+ "autoRecall": true,
11
+ "maxCaptureMessages": 10
12
+ }