@contextableai/openclaw-memory-rebac 0.3.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
  }
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contextableai/openclaw-memory-rebac",
3
- "version": "0.3.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",