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