@contextableai/openclaw-memory-rebac 0.2.0 → 0.3.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/README.md CHANGED
@@ -213,6 +213,41 @@ Session groups (`session-<id>`) provide per-conversation memory isolation:
213
213
  - Other agents cannot read or write to foreign session groups without explicit membership
214
214
  - Session memories are searchable within the session scope and are deduplicated against long-term memories
215
215
 
216
+ ### Per-Agent Identity
217
+
218
+ When multiple agents share a single OpenClaw gateway, each agent gets its own SpiceDB identity. Tools and lifecycle hooks derive the subject from the runtime `agentId` — so `agent:stenographer` and `agent:main` write memories with distinct `shared_by` relationships, even though they run through the same plugin instance.
219
+
220
+ If `agentId` is not available in the runtime context (e.g., older OpenClaw versions or standalone CLI use), the plugin falls back to the config-level `subjectType`/`subjectId`.
221
+
222
+ Session state (session IDs and SpiceDB consistency tokens) is also tracked per agent, so agents don't interfere with each other's sessions.
223
+
224
+ ### Identity Linking
225
+
226
+ The `identities` config field connects agents to the people they represent. This is essential for **cross-agent recall** — finding memories stored by one agent that involve a person represented by a different agent.
227
+
228
+ ```json
229
+ {
230
+ "identities": {
231
+ "main": "U0123ABC",
232
+ "work": "U0456DEF"
233
+ }
234
+ }
235
+ ```
236
+
237
+ Each entry maps an agent ID to a person ID (typically a Slack user ID or other external identifier). At plugin startup, the plugin writes `agent:<agentId> #owner person:<personId>` relationships to SpiceDB.
238
+
239
+ **How cross-agent recall works:**
240
+
241
+ 1. Agent A stores a memory with `involves: ["U0123ABC"]`
242
+ 2. Later, agent B (configured as `"main": "U0123ABC"`) calls `memory_recall`
243
+ 3. The plugin resolves `agent:main` → `person:U0123ABC` via SpiceDB
244
+ 4. It discovers the memory because `person:U0123ABC` is in `involves`
245
+ 5. The memory is returned alongside group-based results
246
+
247
+ This means a user's personal agent can discover memories stored by service agents (like a meeting recorder or Slack observer), as long as the user was a participant. The service agent retains `shared_by` ownership (and exclusive delete permission), while involved people get view access through their own agents.
248
+
249
+ Agents without an `identities` entry (like service agents) are not linked to any person and cannot be resolved through identity chains. This is intentional — a service agent acts on its own behalf, not on behalf of a human.
250
+
216
251
  ## Configuration Reference
217
252
 
218
253
  | Key | Type | Default | Description |
@@ -227,7 +262,8 @@ Session groups (`session-<id>`) provide per-conversation memory isolation:
227
262
  | `graphiti.uuidPollMaxAttempts` | integer | `60` | Max polling attempts (total timeout = interval x attempts) |
228
263
  | `graphiti.requestTimeoutMs` | integer | `30000` | HTTP request timeout for Graphiti REST calls (ms) |
229
264
  | `subjectType` | string | `agent` | SpiceDB subject type (`agent` or `person`) |
230
- | `subjectId` | string | `default` | SpiceDB subject ID (supports `${ENV_VAR}`) |
265
+ | `subjectId` | string | `default` | Fallback SpiceDB subject ID when agentId is unavailable (supports `${ENV_VAR}`) |
266
+ | `identities` | object | `{}` | Maps agent IDs to owner person IDs for cross-agent recall (see [Identity Linking](#identity-linking)) |
231
267
  | `autoCapture` | boolean | `true` | Auto-capture conversations |
232
268
  | `autoRecall` | boolean | `true` | Auto-inject relevant memories |
233
269
  | `customInstructions` | string | *(see below)* | Custom extraction instructions |
@@ -267,13 +303,18 @@ All commands are under `rebac-mem`:
267
303
 
268
304
  | Command | Description |
269
305
  |---------|-------------|
270
- | `rebac-mem search <query>` | Search memories with authorization. Options: `--limit`, `--scope` |
271
- | `rebac-mem status` | Check SpiceDB + backend connectivity |
306
+ | `rebac-mem search <query>` | Search memories with authorization (includes owner-aware recall). Options: `--limit`, `--as` |
307
+ | `rebac-mem status` | Check SpiceDB + backend connectivity, show subject and identity links |
272
308
  | `rebac-mem schema-write` | Write/update the SpiceDB authorization schema |
273
- | `rebac-mem groups` | List authorized groups for the current subject |
309
+ | `rebac-mem groups` | List authorized groups for a subject. Options: `--as` |
274
310
  | `rebac-mem add-member <group-id> <subject-id>` | Add a subject to a group. Options: `--type` |
311
+ | `rebac-mem identities` | List configured identity links and verify them in SpiceDB |
312
+ | `rebac-mem link-identity <agent-id> <person-id>` | Write an agent→owner relationship to SpiceDB |
313
+ | `rebac-mem unlink-identity <agent-id>` | Remove an agent→owner relationship from SpiceDB |
275
314
  | `rebac-mem import` | Import workspace markdown files. Options: `--workspace`, `--include-sessions`, `--group`, `--dry-run` |
276
315
 
316
+ The `--as` flag accepts `"type:id"` (e.g., `"agent:main"`, `"person:U0123ABC"`) or a bare `"id"` (defaults to agent type). Use it to query as a different subject without changing config.
317
+
277
318
  Backend-specific commands (Graphiti):
278
319
 
279
320
  | Command | Description |
@@ -340,6 +381,9 @@ OpenClaw has an exclusive `memory` slot — only one memory plugin is active at
340
381
  },
341
382
  "subjectType": "agent",
342
383
  "subjectId": "my-agent",
384
+ "identities": {
385
+ "my-agent": "U0123ABC"
386
+ },
343
387
  "autoCapture": true,
344
388
  "autoRecall": true
345
389
  }
@@ -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/cli.d.ts CHANGED
@@ -13,6 +13,11 @@ import type { MemoryBackend } from "./backend.js";
13
13
  import type { SpiceDbClient } from "./spicedb.js";
14
14
  import type { RebacMemoryConfig } from "./config.js";
15
15
  import { type Subject } from "./authorization.js";
16
+ /**
17
+ * Parse an --as flag value into a Subject.
18
+ * Accepts "type:id" (e.g., "agent:main", "person:U0123ABC") or bare "id" (defaults to agent type).
19
+ */
20
+ export declare function parseSubjectOverride(value: string): Subject;
16
21
  export type CliContext = {
17
22
  backend: MemoryBackend;
18
23
  spicedb: SpiceDbClient;
package/dist/cli.js CHANGED
@@ -14,7 +14,7 @@ import { join, dirname, basename, resolve } from "node:path";
14
14
  import { homedir } from "node:os";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { defaultGroupId } from "./config.js";
17
- import { lookupAuthorizedGroups, ensureGroupMembership, } from "./authorization.js";
17
+ import { lookupAuthorizedGroups, lookupAgentOwner, lookupViewableFragments, ensureGroupMembership, } from "./authorization.js";
18
18
  import { searchAuthorizedMemories } from "./search.js";
19
19
  // ============================================================================
20
20
  // Session helper (mirrors index.ts — no shared module to avoid circular import)
@@ -24,6 +24,25 @@ function sessionGroupId(sessionId) {
24
24
  return `session-${sanitized}`;
25
25
  }
26
26
  // ============================================================================
27
+ // Subject parsing helper
28
+ // ============================================================================
29
+ /**
30
+ * Parse an --as flag value into a Subject.
31
+ * Accepts "type:id" (e.g., "agent:main", "person:U0123ABC") or bare "id" (defaults to agent type).
32
+ */
33
+ export function parseSubjectOverride(value) {
34
+ const colonIdx = value.indexOf(":");
35
+ if (colonIdx !== -1) {
36
+ const type = value.slice(0, colonIdx);
37
+ const id = value.slice(colonIdx + 1);
38
+ if (type !== "agent" && type !== "person") {
39
+ throw new Error(`Invalid subject type "${type}" — must be "agent" or "person"`);
40
+ }
41
+ return { type, id };
42
+ }
43
+ return { type: "agent", id: value };
44
+ }
45
+ // ============================================================================
27
46
  // Command Registration
28
47
  // ============================================================================
29
48
  export function registerCommands(cmd, ctx) {
@@ -34,26 +53,54 @@ export function registerCommands(cmd, ctx) {
34
53
  // --------------------------------------------------------------------------
35
54
  cmd
36
55
  .command("search")
37
- .description("Search memories with authorization")
56
+ .description("Search memories with authorization (includes owner-aware recall for agents)")
38
57
  .argument("<query>", "Search query")
39
58
  .option("--limit <n>", "Max results", "10")
59
+ .option("--as <subject>", "Override subject (e.g., \"main\", \"agent:main\", \"person:U0123ABC\")")
40
60
  .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;
61
+ const subject = opts.as ? parseSubjectOverride(opts.as) : currentSubject;
62
+ const token = getLastWriteToken();
63
+ // Group-based search
64
+ const authorizedGroups = await lookupAuthorizedGroups(spicedb, subject, token);
65
+ console.log(`Searching as ${subject.type}:${subject.id}...`);
66
+ const groupResults = authorizedGroups.length > 0
67
+ ? await searchAuthorizedMemories(backend, {
68
+ query,
69
+ groupIds: authorizedGroups,
70
+ limit: parseInt(opts.limit),
71
+ })
72
+ : [];
73
+ // Owner-aware recall: if agent, resolve owner and find involves fragments
74
+ let ownerResults = [];
75
+ if (subject.type === "agent" && backend.getFragmentsByIds) {
76
+ try {
77
+ const ownerId = await lookupAgentOwner(spicedb, subject.id, token);
78
+ if (ownerId) {
79
+ const ownerSubject = { type: "person", id: ownerId };
80
+ const viewableIds = await lookupViewableFragments(spicedb, ownerSubject, token);
81
+ if (viewableIds.length > 0) {
82
+ const groupResultIds = new Set(groupResults.map((r) => r.uuid));
83
+ const newIds = viewableIds.filter((id) => !groupResultIds.has(id));
84
+ if (newIds.length > 0) {
85
+ ownerResults = await backend.getFragmentsByIds(newIds);
86
+ }
87
+ }
88
+ if (ownerResults.length > 0) {
89
+ console.log(` + ${ownerResults.length} result(s) via owner identity (person:${ownerId})`);
90
+ }
91
+ }
92
+ }
93
+ catch {
94
+ // Owner lookup failed — proceed with group results only
95
+ }
45
96
  }
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) {
97
+ const allResults = [...groupResults, ...ownerResults];
98
+ if (allResults.length === 0) {
53
99
  console.log("No results found.");
54
100
  return;
55
101
  }
56
- console.log(JSON.stringify(results, null, 2));
102
+ console.log(`Found ${allResults.length} result(s) across ${authorizedGroups.length} group(s).`);
103
+ console.log(JSON.stringify(allResults, null, 2));
57
104
  });
58
105
  // --------------------------------------------------------------------------
59
106
  // status
@@ -77,6 +124,14 @@ export function registerCommands(cmd, ctx) {
77
124
  console.log(` ${k}: ${v}`);
78
125
  }
79
126
  console.log(`SpiceDB: ${spicedbOk ? "OK" : "UNREACHABLE"} (${cfg.spicedb.endpoint})`);
127
+ console.log(`Subject: ${currentSubject.type}:${currentSubject.id}`);
128
+ const identityEntries = Object.entries(cfg.identities);
129
+ if (identityEntries.length > 0) {
130
+ console.log(`Identities: ${identityEntries.length} configured`);
131
+ for (const [agentId, personId] of identityEntries) {
132
+ console.log(` agent:${agentId} → person:${personId}`);
133
+ }
134
+ }
80
135
  });
81
136
  // --------------------------------------------------------------------------
82
137
  // schema-write
@@ -95,14 +150,16 @@ export function registerCommands(cmd, ctx) {
95
150
  // --------------------------------------------------------------------------
96
151
  cmd
97
152
  .command("groups")
98
- .description("List authorized groups for current subject")
99
- .action(async () => {
100
- const groups = await lookupAuthorizedGroups(spicedb, currentSubject, getLastWriteToken());
153
+ .description("List authorized groups for a subject")
154
+ .option("--as <subject>", "Override subject (e.g., \"main\", \"agent:stenographer\")")
155
+ .action(async (opts) => {
156
+ const subject = opts.as ? parseSubjectOverride(opts.as) : currentSubject;
157
+ const groups = await lookupAuthorizedGroups(spicedb, subject, getLastWriteToken());
101
158
  if (groups.length === 0) {
102
- console.log("No authorized groups.");
159
+ console.log(`No authorized groups for ${subject.type}:${subject.id}.`);
103
160
  return;
104
161
  }
105
- console.log(`Authorized groups for ${currentSubject.type}:${currentSubject.id}:`);
162
+ console.log(`Authorized groups for ${subject.type}:${subject.id}:`);
106
163
  for (const g of groups)
107
164
  console.log(` - ${g}`);
108
165
  });
@@ -449,6 +506,93 @@ export function registerCommands(cmd, ctx) {
449
506
  });
450
507
  }
451
508
  // --------------------------------------------------------------------------
509
+ // identities — list and verify agent→owner identity links
510
+ // --------------------------------------------------------------------------
511
+ cmd
512
+ .command("identities")
513
+ .description("List configured identity links and verify them in SpiceDB")
514
+ .action(async () => {
515
+ const identities = cfg.identities;
516
+ const entries = Object.entries(identities);
517
+ if (entries.length === 0) {
518
+ console.log("No identities configured.");
519
+ console.log("Add an \"identities\" field to your config to link agents to their owners:");
520
+ console.log(' { "identities": { "main": "U0123ABC" } }');
521
+ return;
522
+ }
523
+ console.log("Configured identity links:\n");
524
+ for (const [agentId, personId] of entries) {
525
+ let spicedbStatus;
526
+ try {
527
+ const ownerId = await lookupAgentOwner(spicedb, agentId, getLastWriteToken());
528
+ if (ownerId === personId) {
529
+ spicedbStatus = "verified";
530
+ }
531
+ else if (ownerId) {
532
+ spicedbStatus = `mismatch (SpiceDB has person:${ownerId})`;
533
+ }
534
+ else {
535
+ spicedbStatus = "not found in SpiceDB";
536
+ }
537
+ }
538
+ catch {
539
+ spicedbStatus = "SpiceDB unreachable";
540
+ }
541
+ console.log(` agent:${agentId} → person:${personId} [${spicedbStatus}]`);
542
+ }
543
+ console.log("\nTo write missing links, restart the gateway or use: rebac-mem link-identity <agent-id> <person-id>");
544
+ });
545
+ // --------------------------------------------------------------------------
546
+ // link-identity — write an agent→owner relationship to SpiceDB
547
+ // --------------------------------------------------------------------------
548
+ cmd
549
+ .command("link-identity")
550
+ .description("Write an agent→owner relationship to SpiceDB")
551
+ .argument("<agent-id>", "Agent ID")
552
+ .argument("<person-id>", "Owner person ID")
553
+ .action(async (agentId, personId) => {
554
+ try {
555
+ await spicedb.writeRelationships([{
556
+ resourceType: "agent",
557
+ resourceId: agentId,
558
+ relation: "owner",
559
+ subjectType: "person",
560
+ subjectId: personId,
561
+ }]);
562
+ console.log(`Linked agent:${agentId} → person:${personId}`);
563
+ }
564
+ catch (err) {
565
+ console.error(`Failed to write identity link: ${err instanceof Error ? err.message : String(err)}`);
566
+ }
567
+ });
568
+ // --------------------------------------------------------------------------
569
+ // unlink-identity — remove an agent→owner relationship from SpiceDB
570
+ // --------------------------------------------------------------------------
571
+ cmd
572
+ .command("unlink-identity")
573
+ .description("Remove an agent→owner relationship from SpiceDB")
574
+ .argument("<agent-id>", "Agent ID")
575
+ .action(async (agentId) => {
576
+ try {
577
+ const ownerId = await lookupAgentOwner(spicedb, agentId, getLastWriteToken());
578
+ if (!ownerId) {
579
+ console.log(`No owner link found for agent:${agentId}`);
580
+ return;
581
+ }
582
+ await spicedb.deleteRelationships([{
583
+ resourceType: "agent",
584
+ resourceId: agentId,
585
+ relation: "owner",
586
+ subjectType: "person",
587
+ subjectId: ownerId,
588
+ }]);
589
+ console.log(`Unlinked agent:${agentId} (was → person:${ownerId})`);
590
+ }
591
+ catch (err) {
592
+ console.error(`Failed to remove identity link: ${err instanceof Error ? err.message : String(err)}`);
593
+ }
594
+ });
595
+ // --------------------------------------------------------------------------
452
596
  // Backend-specific extension point
453
597
  // --------------------------------------------------------------------------
454
598
  backend.registerCliCommands?.(cmd);
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
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contextableai/openclaw-memory-rebac",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "OpenClaw two-layer memory plugin: SpiceDB ReBAC authorization + Graphiti knowledge graph",
5
5
  "type": "module",
6
6
  "license": "MIT",