@contextableai/openclaw-memory-rebac 0.1.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,6 +22,11 @@ export type FragmentRelationships = {
22
22
  * Returns group resource IDs from SpiceDB where the subject has the "access" permission.
23
23
  */
24
24
  export declare function lookupAuthorizedGroups(spicedb: SpiceDbClient, subject: Subject, zedToken?: string): Promise<string[]>;
25
+ /**
26
+ * Look up the owner person ID for an agent.
27
+ * Returns undefined if no owner relationship exists.
28
+ */
29
+ export declare function lookupAgentOwner(spicedb: SpiceDbClient, agentId: string, zedToken?: string): Promise<string | undefined>;
25
30
  /**
26
31
  * Look up all memory fragment IDs that a subject can view.
27
32
  * Used for fine-grained post-filtering when needed.
@@ -28,6 +28,20 @@ export async function lookupAuthorizedGroups(spicedb, subject, zedToken) {
28
28
  consistency: tokenConsistency(zedToken),
29
29
  });
30
30
  }
31
+ /**
32
+ * Look up the owner person ID for an agent.
33
+ * Returns undefined if no owner relationship exists.
34
+ */
35
+ export async function lookupAgentOwner(spicedb, agentId, zedToken) {
36
+ const tuples = await spicedb.readRelationships({
37
+ resourceType: "agent",
38
+ resourceId: agentId,
39
+ relation: "owner",
40
+ consistency: tokenConsistency(zedToken),
41
+ });
42
+ const ownerTuple = tuples.find((t) => t.subjectType === "person");
43
+ return ownerTuple?.subjectId;
44
+ }
31
45
  /**
32
46
  * Look up all memory fragment IDs that a subject can view.
33
47
  * Used for fine-grained post-filtering when needed.
package/dist/backend.d.ts CHANGED
@@ -118,6 +118,12 @@ export interface MemoryBackend {
118
118
  * Returns true if deleted, false if the backend doesn't support it.
119
119
  */
120
120
  deleteFragment?(uuid: string, type?: string): Promise<boolean>;
121
+ /**
122
+ * Fetch fragment details by their IDs.
123
+ * Used for fragment-level recall (e.g., finding memories via `involves` permissions).
124
+ * Optional: not all backends support fetching individual fragments by ID.
125
+ */
126
+ getFragmentsByIds?(ids: string[]): Promise<SearchResult[]>;
121
127
  /**
122
128
  * Discover fragment (fact/edge) UUIDs that were extracted from a stored episode.
123
129
  * Called after store() resolves the episode ID to write per-fragment SpiceDB
@@ -63,6 +63,7 @@ export declare class GraphitiBackend implements MemoryBackend {
63
63
  listGroups(): Promise<BackendDataset[]>;
64
64
  deleteFragment(uuid: string, type?: string): Promise<boolean>;
65
65
  getEpisodes(groupId: string, lastN: number): Promise<GraphitiEpisode[]>;
66
+ getFragmentsByIds(ids: string[]): Promise<SearchResult[]>;
66
67
  discoverFragmentIds(episodeId: string): Promise<string[]>;
67
68
  getEntityEdge(uuid: string): Promise<FactResult>;
68
69
  registerCliCommands(cmd: Command): void;
@@ -159,6 +159,26 @@ export class GraphitiBackend {
159
159
  async getEpisodes(groupId, lastN) {
160
160
  return this.restCall("GET", `/episodes/${encodeURIComponent(groupId)}?last_n=${lastN}`);
161
161
  }
162
+ async getFragmentsByIds(ids) {
163
+ const results = [];
164
+ for (const id of ids) {
165
+ try {
166
+ const fact = await this.getEntityEdge(id);
167
+ results.push({
168
+ type: "fact",
169
+ uuid: id,
170
+ group_id: "unknown",
171
+ summary: fact.fact,
172
+ context: fact.name,
173
+ created_at: fact.created_at,
174
+ });
175
+ }
176
+ catch {
177
+ // Fragment not found or unreachable — skip
178
+ }
179
+ }
180
+ return results;
181
+ }
162
182
  async discoverFragmentIds(episodeId) {
163
183
  const edges = await this.restCall("GET", `/episodes/${encodeURIComponent(episodeId)}/edges`);
164
184
  return edges.map((e) => e.uuid);
package/dist/config.d.ts CHANGED
@@ -16,6 +16,8 @@ export type RebacMemoryConfig = {
16
16
  backendConfig: Record<string, unknown>;
17
17
  subjectType: "agent" | "person";
18
18
  subjectId: string;
19
+ /** Maps agent IDs to their owner person IDs (e.g., Slack user IDs). */
20
+ identities: Record<string, string>;
19
21
  autoCapture: boolean;
20
22
  autoRecall: boolean;
21
23
  maxCaptureMessages: number;
package/dist/config.js CHANGED
@@ -41,7 +41,7 @@ export const rebacMemoryConfigSchema = {
41
41
  // Top-level allowed keys: shared keys + the backend name key
42
42
  assertAllowedKeys(cfg, [
43
43
  "backend", "spicedb",
44
- "subjectType", "subjectId",
44
+ "subjectType", "subjectId", "identities",
45
45
  "autoCapture", "autoRecall", "maxCaptureMessages",
46
46
  backendName,
47
47
  ], "openclaw-memory-rebac config");
@@ -54,6 +54,16 @@ export const rebacMemoryConfigSchema = {
54
54
  const backendConfig = { ...entry.defaults, ...backendRaw };
55
55
  const subjectType = cfg.subjectType === "person" ? "person" : pluginDefaults.subjectType;
56
56
  const subjectId = typeof cfg.subjectId === "string" ? resolveEnvVars(cfg.subjectId) : pluginDefaults.subjectId;
57
+ // Parse identities: { "main": "U0123ABC", "work": "U0456DEF" }
58
+ const identitiesRaw = cfg.identities;
59
+ const identities = {};
60
+ if (identitiesRaw && typeof identitiesRaw === "object" && !Array.isArray(identitiesRaw)) {
61
+ for (const [agentId, personId] of Object.entries(identitiesRaw)) {
62
+ if (typeof personId === "string" && personId.trim()) {
63
+ identities[agentId] = personId.trim();
64
+ }
65
+ }
66
+ }
57
67
  return {
58
68
  backend: backendName,
59
69
  spicedb: {
@@ -68,6 +78,7 @@ export const rebacMemoryConfigSchema = {
68
78
  backendConfig,
69
79
  subjectType,
70
80
  subjectId,
81
+ identities,
71
82
  autoCapture: cfg.autoCapture !== false,
72
83
  autoRecall: cfg.autoRecall !== false,
73
84
  maxCaptureMessages: typeof cfg.maxCaptureMessages === "number" && cfg.maxCaptureMessages > 0
package/dist/index.d.ts CHANGED
@@ -10,6 +10,10 @@
10
10
  * Authorization is enforced at the data layer, not in prompts.
11
11
  *
12
12
  * Backend currently uses Graphiti for knowledge graph storage.
13
+ *
14
+ * Per-agent identity: tools and hooks derive the SpiceDB subject from
15
+ * the runtime agentId (OpenClawPluginToolContext / PluginHookAgentContext),
16
+ * falling back to config-level subjectType/subjectId when agentId is absent.
13
17
  */
14
18
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
15
19
  declare const rebacMemoryPlugin: {
package/dist/index.js CHANGED
@@ -10,6 +10,10 @@
10
10
  * Authorization is enforced at the data layer, not in prompts.
11
11
  *
12
12
  * Backend currently uses Graphiti for knowledge graph storage.
13
+ *
14
+ * Per-agent identity: tools and hooks derive the SpiceDB subject from
15
+ * the runtime agentId (OpenClawPluginToolContext / PluginHookAgentContext),
16
+ * falling back to config-level subjectType/subjectId when agentId is absent.
13
17
  */
14
18
  import { Type } from "@sinclair/typebox";
15
19
  import { readFileSync } from "node:fs";
@@ -17,7 +21,7 @@ import { join, dirname } from "node:path";
17
21
  import { fileURLToPath } from "node:url";
18
22
  import { rebacMemoryConfigSchema, createBackend, defaultGroupId } from "./config.js";
19
23
  import { SpiceDbClient } from "./spicedb.js";
20
- import { lookupAuthorizedGroups, writeFragmentRelationships, deleteFragmentRelationships, canDeleteFragment, canWriteToGroup, ensureGroupMembership, } from "./authorization.js";
24
+ import { lookupAuthorizedGroups, lookupViewableFragments, lookupAgentOwner, writeFragmentRelationships, deleteFragmentRelationships, canDeleteFragment, canWriteToGroup, ensureGroupMembership, } from "./authorization.js";
21
25
  import { searchAuthorizedMemories, formatDualResults, deduplicateSessionResults, } from "./search.js";
22
26
  import { registerCommands } from "./cli.js";
23
27
  // ============================================================================
@@ -64,14 +68,32 @@ const rebacMemoryPlugin = {
64
68
  process.removeListener("unhandledRejection", grpcRejectionHandler);
65
69
  }, 10_000);
66
70
  grpcGuardTimer.unref();
67
- const currentSubject = { type: cfg.subjectType, id: cfg.subjectId };
68
- let currentSessionId;
69
- let lastWriteToken;
71
+ // Per-agent state: keyed by agentId (falls back to cfg.subjectId)
72
+ const agentStates = new Map();
73
+ function resolveSubject(agentId) {
74
+ if (agentId)
75
+ return { type: "agent", id: agentId };
76
+ return { type: cfg.subjectType, id: cfg.subjectId };
77
+ }
78
+ function getState(agentId) {
79
+ const key = agentId ?? cfg.subjectId;
80
+ let state = agentStates.get(key);
81
+ if (!state) {
82
+ state = {};
83
+ agentStates.set(key, state);
84
+ }
85
+ return state;
86
+ }
87
+ // Convenience: read state from the config-level default
88
+ // (used by service start and CLI where no agentId is available)
89
+ function getDefaultState() {
90
+ return getState(undefined);
91
+ }
70
92
  api.logger.info(`openclaw-memory-rebac: registered (backend: ${backend.name}, spicedb: ${cfg.spicedb.endpoint})`);
71
93
  // ========================================================================
72
- // Tools
94
+ // Tools (registered as factories for per-agent identity)
73
95
  // ========================================================================
74
- api.registerTool({
96
+ api.registerTool((ctx) => ({
75
97
  name: "memory_recall",
76
98
  label: "Memory Recall",
77
99
  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.",
@@ -82,20 +104,16 @@ const rebacMemoryPlugin = {
82
104
  }),
83
105
  async execute(_toolCallId, params) {
84
106
  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
- }
107
+ const subject = resolveSubject(ctx.agentId);
108
+ const state = getState(ctx.agentId);
109
+ const authorizedGroups = await lookupAuthorizedGroups(spicedb, subject, state.lastWriteToken);
92
110
  let longTermGroups;
93
111
  let sessionGroups;
94
112
  if (scope === "session") {
95
113
  longTermGroups = [];
96
114
  sessionGroups = authorizedGroups.filter(isSessionGroup);
97
- if (currentSessionId) {
98
- const sg = sessionGroupId(currentSessionId);
115
+ if (state.sessionId) {
116
+ const sg = sessionGroupId(state.sessionId);
99
117
  if (!sessionGroups.includes(sg))
100
118
  sessionGroups.push(sg);
101
119
  }
@@ -107,19 +125,20 @@ const rebacMemoryPlugin = {
107
125
  else {
108
126
  longTermGroups = authorizedGroups.filter((g) => !isSessionGroup(g));
109
127
  sessionGroups = authorizedGroups.filter(isSessionGroup);
110
- if (currentSessionId) {
111
- const sg = sessionGroupId(currentSessionId);
128
+ if (state.sessionId) {
129
+ const sg = sessionGroupId(state.sessionId);
112
130
  if (!sessionGroups.includes(sg))
113
131
  sessionGroups.push(sg);
114
132
  }
115
133
  }
134
+ // Group-based search (existing path)
116
135
  const [longTermResults, rawSessionResults] = await Promise.all([
117
136
  longTermGroups.length > 0
118
137
  ? searchAuthorizedMemories(backend, {
119
138
  query,
120
139
  groupIds: longTermGroups,
121
140
  limit,
122
- sessionId: currentSessionId,
141
+ sessionId: state.sessionId,
123
142
  })
124
143
  : Promise.resolve([]),
125
144
  sessionGroups.length > 0
@@ -127,20 +146,45 @@ const rebacMemoryPlugin = {
127
146
  query,
128
147
  groupIds: sessionGroups,
129
148
  limit,
130
- sessionId: currentSessionId,
149
+ sessionId: state.sessionId,
131
150
  })
132
151
  : Promise.resolve([]),
133
152
  ]);
134
153
  const sessionResults = deduplicateSessionResults(longTermResults, rawSessionResults);
135
- const totalCount = longTermResults.length + sessionResults.length;
154
+ const groupResults = [...longTermResults, ...sessionResults];
155
+ // Owner-aware fragment search: if the subject is an agent with an owner,
156
+ // also find fragments where the owner is in `involves`.
157
+ let ownerFragmentResults = [];
158
+ if (subject.type === "agent" && backend.getFragmentsByIds) {
159
+ try {
160
+ const ownerId = await lookupAgentOwner(spicedb, subject.id, state.lastWriteToken);
161
+ if (ownerId) {
162
+ const ownerSubject = { type: "person", id: ownerId };
163
+ const viewableIds = await lookupViewableFragments(spicedb, ownerSubject, state.lastWriteToken);
164
+ if (viewableIds.length > 0) {
165
+ const groupResultIds = new Set(groupResults.map((r) => r.uuid));
166
+ const newIds = viewableIds.filter((id) => !groupResultIds.has(id));
167
+ if (newIds.length > 0) {
168
+ ownerFragmentResults = await backend.getFragmentsByIds(newIds);
169
+ }
170
+ }
171
+ }
172
+ }
173
+ catch (err) {
174
+ api.logger.warn(`openclaw-memory-rebac: owner-aware recall failed: ${String(err)}`);
175
+ }
176
+ }
177
+ const allResults = [...groupResults, ...ownerFragmentResults];
178
+ const totalCount = allResults.length;
136
179
  if (totalCount === 0) {
137
180
  return {
138
181
  content: [{ type: "text", text: "No relevant memories found." }],
139
182
  details: { count: 0, authorizedGroups },
140
183
  };
141
184
  }
142
- const text = formatDualResults(longTermResults, sessionResults);
143
- const allResults = [...longTermResults, ...sessionResults];
185
+ const text = ownerFragmentResults.length > 0
186
+ ? formatDualResults(groupResults, ownerFragmentResults)
187
+ : formatDualResults(longTermResults, sessionResults);
144
188
  const sanitized = allResults.map((r) => ({
145
189
  type: r.type,
146
190
  uuid: r.uuid,
@@ -156,11 +200,12 @@ const rebacMemoryPlugin = {
156
200
  authorizedGroups,
157
201
  longTermCount: longTermResults.length,
158
202
  sessionCount: sessionResults.length,
203
+ ownerFragmentCount: ownerFragmentResults.length,
159
204
  },
160
205
  };
161
206
  },
162
- }, { name: "memory_recall" });
163
- api.registerTool({
207
+ }), { name: "memory_recall" });
208
+ api.registerTool((ctx) => ({
164
209
  name: "memory_store",
165
210
  label: "Memory Store",
166
211
  description: "Save information to the knowledge graph with authorization tracking. Use longTerm=false to store session-scoped memories.",
@@ -173,6 +218,8 @@ const rebacMemoryPlugin = {
173
218
  }),
174
219
  async execute(_toolCallId, params) {
175
220
  const { content, source_description = "conversation", involves = [], group_id, longTerm = true, } = params;
221
+ const subject = resolveSubject(ctx.agentId);
222
+ const state = getState(ctx.agentId);
176
223
  const sanitizeGroupId = (id) => {
177
224
  if (!id)
178
225
  return undefined;
@@ -187,27 +234,27 @@ const rebacMemoryPlugin = {
187
234
  if (sanitizedGroupId) {
188
235
  targetGroupId = sanitizedGroupId;
189
236
  }
190
- else if (!longTerm && currentSessionId) {
191
- targetGroupId = sessionGroupId(currentSessionId);
237
+ else if (!longTerm && state.sessionId) {
238
+ targetGroupId = sessionGroupId(state.sessionId);
192
239
  }
193
240
  else {
194
241
  targetGroupId = backendDefaultGroupId;
195
242
  }
196
243
  const isOwnSession = isSessionGroup(targetGroupId) &&
197
- currentSessionId != null &&
198
- targetGroupId === sessionGroupId(currentSessionId);
244
+ state.sessionId != null &&
245
+ targetGroupId === sessionGroupId(state.sessionId);
199
246
  if (isOwnSession) {
200
247
  try {
201
- const token = await ensureGroupMembership(spicedb, targetGroupId, currentSubject);
248
+ const token = await ensureGroupMembership(spicedb, targetGroupId, subject);
202
249
  if (token)
203
- lastWriteToken = token;
250
+ state.lastWriteToken = token;
204
251
  }
205
252
  catch {
206
253
  api.logger.warn(`openclaw-memory-rebac: failed to ensure membership in ${targetGroupId}`);
207
254
  }
208
255
  }
209
256
  else {
210
- const allowed = await canWriteToGroup(spicedb, currentSubject, targetGroupId, lastWriteToken);
257
+ const allowed = await canWriteToGroup(spicedb, subject, targetGroupId, state.lastWriteToken);
211
258
  if (!allowed) {
212
259
  return {
213
260
  content: [{ type: "text", text: `Permission denied: cannot write to group "${targetGroupId}"` }],
@@ -236,11 +283,11 @@ const rebacMemoryPlugin = {
236
283
  const writeToken = await writeFragmentRelationships(spicedb, {
237
284
  fragmentId: factId,
238
285
  groupId: targetGroupId,
239
- sharedBy: currentSubject,
286
+ sharedBy: subject,
240
287
  involves: involvedSubjects,
241
288
  });
242
289
  if (writeToken)
243
- lastWriteToken = writeToken;
290
+ state.lastWriteToken = writeToken;
244
291
  }
245
292
  api.logger.info(`openclaw-memory-rebac: wrote SpiceDB relationships for ${factIds.length} fact(s) from episode ${episodeId}`);
246
293
  }
@@ -249,11 +296,11 @@ const rebacMemoryPlugin = {
249
296
  const writeToken = await writeFragmentRelationships(spicedb, {
250
297
  fragmentId: episodeId,
251
298
  groupId: targetGroupId,
252
- sharedBy: currentSubject,
299
+ sharedBy: subject,
253
300
  involves: involvedSubjects,
254
301
  });
255
302
  if (writeToken)
256
- lastWriteToken = writeToken;
303
+ state.lastWriteToken = writeToken;
257
304
  }
258
305
  })
259
306
  .catch((err) => {
@@ -270,8 +317,8 @@ const rebacMemoryPlugin = {
270
317
  },
271
318
  };
272
319
  },
273
- }, { name: "memory_store" });
274
- api.registerTool({
320
+ }), { name: "memory_store" });
321
+ api.registerTool((ctx) => ({
275
322
  name: "memory_forget",
276
323
  label: "Memory Forget",
277
324
  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.",
@@ -280,6 +327,8 @@ const rebacMemoryPlugin = {
280
327
  }),
281
328
  async execute(_toolCallId, params) {
282
329
  const { id } = params;
330
+ const subject = resolveSubject(ctx.agentId);
331
+ const state = getState(ctx.agentId);
283
332
  // Parse optional type prefix — strip it to get the bare UUID
284
333
  const colonIdx = id.indexOf(":");
285
334
  let uuid;
@@ -303,11 +352,11 @@ const rebacMemoryPlugin = {
303
352
  // Fragment-level relationships may be missing (episode UUID vs fact UUID mismatch),
304
353
  // so fall back to group-level authorization: if the subject can contribute to any
305
354
  // group they have access to, allow deletion.
306
- let allowed = await canDeleteFragment(spicedb, currentSubject, uuid, lastWriteToken);
355
+ let allowed = await canDeleteFragment(spicedb, subject, uuid, state.lastWriteToken);
307
356
  if (!allowed) {
308
- const groups = await lookupAuthorizedGroups(spicedb, currentSubject, lastWriteToken);
357
+ const groups = await lookupAuthorizedGroups(spicedb, subject, state.lastWriteToken);
309
358
  for (const g of groups) {
310
- if (await canWriteToGroup(spicedb, currentSubject, g, lastWriteToken)) {
359
+ if (await canWriteToGroup(spicedb, subject, g, state.lastWriteToken)) {
311
360
  allowed = true;
312
361
  break;
313
362
  }
@@ -335,19 +384,20 @@ const rebacMemoryPlugin = {
335
384
  // Always de-authorize in SpiceDB
336
385
  const writeToken = await deleteFragmentRelationships(spicedb, uuid);
337
386
  if (writeToken)
338
- lastWriteToken = writeToken;
387
+ state.lastWriteToken = writeToken;
339
388
  return {
340
389
  content: [{ type: "text", text: "Memory forgotten." }],
341
390
  details: { action: "deleted", id, uuid },
342
391
  };
343
392
  },
344
- }, { name: "memory_forget" });
345
- api.registerTool({
393
+ }), { name: "memory_forget" });
394
+ api.registerTool((ctx) => ({
346
395
  name: "memory_status",
347
396
  label: "Memory Status",
348
397
  description: "Check the health of the memory backend and SpiceDB.",
349
398
  parameters: Type.Object({}),
350
399
  async execute() {
400
+ const state = getState(ctx.agentId);
351
401
  const backendStatus = await backend.getStatus();
352
402
  let spicedbOk = false;
353
403
  try {
@@ -361,22 +411,25 @@ const rebacMemoryPlugin = {
361
411
  ...backendStatus,
362
412
  spicedb: spicedbOk ? "connected" : "unreachable",
363
413
  endpoint_spicedb: cfg.spicedb.endpoint,
364
- currentSessionId: currentSessionId ?? "none",
414
+ currentSessionId: state.sessionId ?? "none",
415
+ agentId: ctx.agentId ?? "default",
365
416
  };
366
417
  const statusText = [
367
418
  `Backend (${backend.name}): ${backendStatus.healthy ? "connected" : "unreachable"}`,
368
419
  `SpiceDB: ${spicedbOk ? "connected" : "unreachable"} (${cfg.spicedb.endpoint})`,
369
- `Session: ${currentSessionId ?? "none"}`,
420
+ `Session: ${state.sessionId ?? "none"}`,
421
+ `Agent: ${ctx.agentId ?? "default"}`,
370
422
  ].join("\n");
371
423
  return {
372
424
  content: [{ type: "text", text: statusText }],
373
425
  details: status,
374
426
  };
375
427
  },
376
- }, { name: "memory_status" });
428
+ }), { name: "memory_status" });
377
429
  // ========================================================================
378
430
  // CLI Commands
379
431
  // ========================================================================
432
+ const defaultSubject = { type: cfg.subjectType, id: cfg.subjectId };
380
433
  api.registerCli(({ program }) => {
381
434
  const mem = program
382
435
  .command("rebac-mem")
@@ -385,8 +438,8 @@ const rebacMemoryPlugin = {
385
438
  backend,
386
439
  spicedb,
387
440
  cfg,
388
- currentSubject,
389
- getLastWriteToken: () => lastWriteToken,
441
+ currentSubject: defaultSubject,
442
+ getLastWriteToken: () => getDefaultState().lastWriteToken,
390
443
  });
391
444
  }, { commands: ["rebac-mem"] });
392
445
  // ========================================================================
@@ -394,18 +447,18 @@ const rebacMemoryPlugin = {
394
447
  // ========================================================================
395
448
  if (cfg.autoRecall) {
396
449
  api.on("before_agent_start", async (event, ctx) => {
450
+ const subject = resolveSubject(ctx?.agentId);
451
+ const state = getState(ctx?.agentId);
397
452
  if (ctx?.sessionKey)
398
- currentSessionId = ctx.sessionKey;
453
+ state.sessionId = ctx.sessionKey;
399
454
  if (!event.prompt || event.prompt.length < 5)
400
455
  return;
401
456
  try {
402
- const authorizedGroups = await lookupAuthorizedGroups(spicedb, currentSubject, lastWriteToken);
403
- if (authorizedGroups.length === 0)
404
- return;
457
+ const authorizedGroups = await lookupAuthorizedGroups(spicedb, subject, state.lastWriteToken);
405
458
  const longTermGroups = authorizedGroups.filter((g) => !isSessionGroup(g));
406
459
  const sessionGroups = authorizedGroups.filter(isSessionGroup);
407
- if (currentSessionId) {
408
- const sg = sessionGroupId(currentSessionId);
460
+ if (state.sessionId) {
461
+ const sg = sessionGroupId(state.sessionId);
409
462
  if (!sessionGroups.includes(sg))
410
463
  sessionGroups.push(sg);
411
464
  }
@@ -415,7 +468,7 @@ const rebacMemoryPlugin = {
415
468
  query: event.prompt,
416
469
  groupIds: longTermGroups,
417
470
  limit: 5,
418
- sessionId: currentSessionId,
471
+ sessionId: state.sessionId,
419
472
  })
420
473
  : Promise.resolve([]),
421
474
  sessionGroups.length > 0
@@ -423,7 +476,7 @@ const rebacMemoryPlugin = {
423
476
  query: event.prompt,
424
477
  groupIds: sessionGroups,
425
478
  limit: 3,
426
- sessionId: currentSessionId,
479
+ sessionId: state.sessionId,
427
480
  })
428
481
  : Promise.resolve([]),
429
482
  ]);
@@ -449,8 +502,10 @@ const rebacMemoryPlugin = {
449
502
  }
450
503
  if (cfg.autoCapture) {
451
504
  api.on("agent_end", async (event, ctx) => {
505
+ const subject = resolveSubject(ctx?.agentId);
506
+ const state = getState(ctx?.agentId);
452
507
  if (ctx?.sessionKey)
453
- currentSessionId = ctx.sessionKey;
508
+ state.sessionId = ctx.sessionKey;
454
509
  if (!event.success || !event.messages || event.messages.length === 0)
455
510
  return;
456
511
  try {
@@ -495,24 +550,24 @@ const rebacMemoryPlugin = {
495
550
  if (conversationLines.length === 0)
496
551
  return;
497
552
  const episodeBody = conversationLines.join("\n");
498
- const targetGroupId = currentSessionId
499
- ? sessionGroupId(currentSessionId)
553
+ const targetGroupId = state.sessionId
554
+ ? sessionGroupId(state.sessionId)
500
555
  : backendDefaultGroupId;
501
556
  const isOwnSession = isSessionGroup(targetGroupId) &&
502
- currentSessionId != null &&
503
- targetGroupId === sessionGroupId(currentSessionId);
557
+ state.sessionId != null &&
558
+ targetGroupId === sessionGroupId(state.sessionId);
504
559
  if (isOwnSession) {
505
560
  try {
506
- const token = await ensureGroupMembership(spicedb, targetGroupId, currentSubject);
561
+ const token = await ensureGroupMembership(spicedb, targetGroupId, subject);
507
562
  if (token)
508
- lastWriteToken = token;
563
+ state.lastWriteToken = token;
509
564
  }
510
565
  catch {
511
566
  // best-effort
512
567
  }
513
568
  }
514
569
  else {
515
- const allowed = await canWriteToGroup(spicedb, currentSubject, targetGroupId, lastWriteToken);
570
+ const allowed = await canWriteToGroup(spicedb, subject, targetGroupId, state.lastWriteToken);
516
571
  if (!allowed) {
517
572
  api.logger.warn(`openclaw-memory-rebac: auto-capture denied for group ${targetGroupId}`);
518
573
  return;
@@ -535,27 +590,27 @@ const rebacMemoryPlugin = {
535
590
  const writeToken = await writeFragmentRelationships(spicedb, {
536
591
  fragmentId: factId,
537
592
  groupId: targetGroupId,
538
- sharedBy: currentSubject,
593
+ sharedBy: subject,
539
594
  });
540
595
  if (writeToken)
541
- lastWriteToken = writeToken;
596
+ state.lastWriteToken = writeToken;
542
597
  }
543
598
  }
544
599
  else {
545
600
  const writeToken = await writeFragmentRelationships(spicedb, {
546
601
  fragmentId: episodeId,
547
602
  groupId: targetGroupId,
548
- sharedBy: currentSubject,
603
+ sharedBy: subject,
549
604
  });
550
605
  if (writeToken)
551
- lastWriteToken = writeToken;
606
+ state.lastWriteToken = writeToken;
552
607
  }
553
608
  })
554
609
  .catch((err) => {
555
610
  api.logger.warn(`openclaw-memory-rebac: deferred SpiceDB write (auto-capture) failed: ${err}`);
556
611
  });
557
612
  // Backend-specific session enrichment (optional backend feature)
558
- if (backend.enrichSession && currentSessionId) {
613
+ if (backend.enrichSession && state.sessionId) {
559
614
  const lastUserMsg = [...event.messages]
560
615
  .reverse()
561
616
  .find((m) => m.role === "user");
@@ -581,7 +636,7 @@ const rebacMemoryPlugin = {
581
636
  const assistantMsg = extractText(lastAssistMsg);
582
637
  if (userMsg && assistantMsg) {
583
638
  backend.enrichSession({
584
- sessionId: currentSessionId,
639
+ sessionId: state.sessionId,
585
640
  groupId: targetGroupId,
586
641
  userMsg,
587
642
  assistantMsg,
@@ -601,6 +656,7 @@ const rebacMemoryPlugin = {
601
656
  api.registerService({
602
657
  id: "openclaw-memory-rebac",
603
658
  async start() {
659
+ const defaultState = getDefaultState();
604
660
  const backendStatus = await backend.getStatus();
605
661
  let spicedbOk = false;
606
662
  try {
@@ -618,14 +674,33 @@ const rebacMemoryPlugin = {
618
674
  // Will be retried on first use
619
675
  }
620
676
  if (spicedbOk) {
677
+ // Ensure the config-level subject is a member of the default group
621
678
  try {
622
- const token = await ensureGroupMembership(spicedb, backendDefaultGroupId, currentSubject);
679
+ const token = await ensureGroupMembership(spicedb, backendDefaultGroupId, defaultSubject);
623
680
  if (token)
624
- lastWriteToken = token;
681
+ defaultState.lastWriteToken = token;
625
682
  }
626
683
  catch {
627
684
  api.logger.warn("openclaw-memory-rebac: failed to ensure default group membership");
628
685
  }
686
+ // Write agent → owner relationships from identities config
687
+ for (const [agentId, personId] of Object.entries(cfg.identities)) {
688
+ try {
689
+ const token = await spicedb.writeRelationships([{
690
+ resourceType: "agent",
691
+ resourceId: agentId,
692
+ relation: "owner",
693
+ subjectType: "person",
694
+ subjectId: personId,
695
+ }]);
696
+ if (token)
697
+ defaultState.lastWriteToken = token;
698
+ api.logger.info(`openclaw-memory-rebac: linked agent:${agentId} → person:${personId}`);
699
+ }
700
+ catch (err) {
701
+ api.logger.warn(`openclaw-memory-rebac: failed to write owner for agent:${agentId}: ${err}`);
702
+ }
703
+ }
629
704
  }
630
705
  api.logger.info(`openclaw-memory-rebac: initialized (backend: ${backend.name} ${backendStatus.healthy ? "OK" : "UNREACHABLE"}, spicedb: ${spicedbOk ? "OK" : "UNREACHABLE"})`);
631
706
  },
@@ -49,7 +49,7 @@ services:
49
49
  dockerfile: Dockerfile
50
50
  restart: unless-stopped
51
51
  ports:
52
- - "8000:8000"
52
+ - "127.0.0.1:8000:8000"
53
53
  environment:
54
54
  # -- Graph database (Neo4j) --
55
55
  NEO4J_URI: bolt://neo4j:7687
@@ -56,8 +56,8 @@ services:
56
56
  command: serve
57
57
  restart: unless-stopped
58
58
  ports:
59
- - "${SPICEDB_GRPC_PORT:-50051}:50051" # gRPC
60
- - "${SPICEDB_HTTP_PORT:-8080}:8080" # HTTP metrics / healthz
59
+ - "127.0.0.1:${SPICEDB_GRPC_PORT:-50051}:50051" # gRPC
60
+ - "127.0.0.1:${SPICEDB_HTTP_PORT:-8080}:8080" # HTTP metrics / healthz
61
61
  environment:
62
62
  SPICEDB_GRPC_PRESHARED_KEY: ${SPICEDB_PRESHARED_KEY:-dev_token}
63
63
  SPICEDB_DATASTORE_ENGINE: postgres
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contextableai/openclaw-memory-rebac",
3
- "version": "0.1.5",
3
+ "version": "0.3.0",
4
4
  "description": "OpenClaw two-layer memory plugin: SpiceDB ReBAC authorization + Graphiti knowledge graph",
5
5
  "type": "module",
6
6
  "license": "MIT",