@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 +48 -4
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +163 -19
- package/package.json +1 -1
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`, `--
|
|
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
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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(
|
|
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
|
|
99
|
-
.
|
|
100
|
-
|
|
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(
|
|
159
|
+
console.log(`No authorized groups for ${subject.type}:${subject.id}.`);
|
|
103
160
|
return;
|
|
104
161
|
}
|
|
105
|
-
console.log(`Authorized groups for ${
|
|
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