@contextableai/openclaw-memory-rebac 0.3.8 → 0.4.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.
@@ -0,0 +1,404 @@
1
+ /**
2
+ * EverMemOSBackend — MemoryBackend implementation backed by the EverMemOS FastAPI REST server.
3
+ *
4
+ * EverMemOS communicates via standard HTTP REST endpoints on port 1995.
5
+ * Messages are processed through the MemCell pipeline: boundary detection →
6
+ * parallel LLM extraction of episodic memories, foresight, event logs, and profiles.
7
+ *
8
+ * store() returns immediately with a generated message_id as the fragment anchor.
9
+ * With @timeout_to_background(), store may return 202 Accepted for background processing.
10
+ *
11
+ * discoverFragmentIds() polls a custom trace overlay endpoint to resolve the message_id
12
+ * to actual MongoDB ObjectIds of derived memories. These ObjectIds are what search
13
+ * results return, enabling fragment-level SpiceDB authorization (involves, share).
14
+ *
15
+ * resolveAnchors() provides lazy resolution at recall time: if discoverFragmentIds()
16
+ * timed out during store, unresolved anchors can be resolved later when someone
17
+ * actually tries to recall the involved fragments.
18
+ */
19
+ import { randomUUID } from "node:crypto";
20
+ import { request as undiciRequest } from "undici";
21
+ /**
22
+ * Map EverMemOS memory types to the SearchResult type union.
23
+ *
24
+ * episodic_memory → "chunk" (narrative text chunks)
25
+ * profile → "summary" (distilled user characteristics)
26
+ * foresight → "summary" (future-oriented predictions)
27
+ * event_log → "fact" (discrete factual events)
28
+ */
29
+ function mapMemoryType(memoryType) {
30
+ switch (memoryType) {
31
+ case "episodic_memory": return "chunk";
32
+ case "event_log": return "fact";
33
+ case "profile":
34
+ case "foresight":
35
+ return "summary";
36
+ default: return "chunk";
37
+ }
38
+ }
39
+ /**
40
+ * Build a context prefix from the EverMemOS memory type.
41
+ * This allows downstream consumers to distinguish memory kinds.
42
+ */
43
+ function contextPrefix(memoryType) {
44
+ switch (memoryType) {
45
+ case "episodic_memory": return "episode";
46
+ case "profile": return "profile";
47
+ case "foresight": return "foresight";
48
+ case "event_log": return "event";
49
+ default: return memoryType;
50
+ }
51
+ }
52
+ /**
53
+ * Extract the primary content from an EverMemOS memory based on its type.
54
+ * Each memory type stores its content in a different field.
55
+ */
56
+ function extractContent(m) {
57
+ switch (m.memory_type) {
58
+ case "episodic_memory": return m.episode ?? m.summary ?? "";
59
+ case "foresight": return m.foresight ?? "";
60
+ case "event_log": return m.summary ?? "";
61
+ case "profile": return m.summary ?? "";
62
+ default: return m.summary ?? m.episode ?? "";
63
+ }
64
+ }
65
+ export class EverMemOSBackend {
66
+ config;
67
+ name = "evermemos";
68
+ requestTimeoutMs;
69
+ /**
70
+ * Tracks pending stores: messageId → groupId.
71
+ * Populated by store(), consumed by discoverFragmentIds().
72
+ */
73
+ pendingStores = new Map();
74
+ constructor(config) {
75
+ this.config = config;
76
+ this.requestTimeoutMs = config.requestTimeoutMs ?? 30000;
77
+ }
78
+ // --------------------------------------------------------------------------
79
+ // REST transport
80
+ // --------------------------------------------------------------------------
81
+ async restCall(method, path, body, queryParams) {
82
+ let url = `${this.config.endpoint}${path}`;
83
+ if (queryParams) {
84
+ const params = new URLSearchParams(queryParams);
85
+ url += `?${params.toString()}`;
86
+ }
87
+ // Node.js fetch rejects body on GET requests (per spec). EverMemOS's
88
+ // search endpoint is GET-only with a JSON request body, so we use
89
+ // undici.request which permits body on any method.
90
+ if (method === "GET" && body !== undefined) {
91
+ const res = await undiciRequest(url, {
92
+ method: "GET",
93
+ headers: { "content-type": "application/json" },
94
+ body: JSON.stringify(body),
95
+ signal: AbortSignal.timeout(this.requestTimeoutMs),
96
+ });
97
+ if (res.statusCode < 200 || res.statusCode >= 300) {
98
+ const text = await res.body.text().catch(() => "");
99
+ throw new Error(`EverMemOS REST ${method} ${path} failed: ${res.statusCode} ${text}`);
100
+ }
101
+ const ct = res.headers["content-type"] ?? "";
102
+ if (ct.includes("application/json")) {
103
+ return (await res.body.json());
104
+ }
105
+ return {};
106
+ }
107
+ const opts = {
108
+ method,
109
+ headers: { "Content-Type": "application/json" },
110
+ signal: AbortSignal.timeout(this.requestTimeoutMs),
111
+ };
112
+ if (body !== undefined) {
113
+ opts.body = JSON.stringify(body);
114
+ }
115
+ const response = await fetch(url, opts);
116
+ if (!response.ok) {
117
+ const text = await response.text().catch(() => "");
118
+ throw new Error(`EverMemOS REST ${method} ${path} failed: ${response.status} ${text}`);
119
+ }
120
+ const ct = response.headers.get("content-type") ?? "";
121
+ if (ct.includes("application/json")) {
122
+ return (await response.json());
123
+ }
124
+ return {};
125
+ }
126
+ // --------------------------------------------------------------------------
127
+ // MemoryBackend implementation
128
+ // --------------------------------------------------------------------------
129
+ async store(params) {
130
+ const messageId = randomUUID();
131
+ const request = {
132
+ message_id: messageId,
133
+ create_time: new Date().toISOString(),
134
+ sender: this.config.defaultSenderId,
135
+ content: params.content,
136
+ group_id: params.groupId,
137
+ role: "user",
138
+ refer_list: [],
139
+ };
140
+ await this.restCall("POST", "/api/v1/memories", request);
141
+ // EverMemOS processes messages asynchronously through its MemCell pipeline.
142
+ // customPrompt is ignored — EverMemOS handles extraction internally.
143
+ //
144
+ // We return our generated message_id as the fragment anchor for SpiceDB.
145
+ // discoverFragmentIds() will resolve this to actual MongoDB ObjectIds via
146
+ // the trace overlay endpoint, enabling fragment-level involves/share.
147
+ this.pendingStores.set(messageId, { groupId: params.groupId });
148
+ return { fragmentId: Promise.resolve(messageId) };
149
+ }
150
+ async searchGroup(params) {
151
+ const { query, groupId, limit } = params;
152
+ const body = {
153
+ query,
154
+ group_id: groupId,
155
+ top_k: limit,
156
+ retrieve_method: this.config.retrieveMethod,
157
+ memory_types: this.config.memoryTypes,
158
+ };
159
+ const response = await this.restCall("GET", "/api/v1/memories/search", body);
160
+ // Response nests memories under group-id keys:
161
+ // result.memories: [{ "group-a": [mem1, mem2, ...] }]
162
+ // result.scores: [{ "group-a": [0.95, 0.87, ...] }]
163
+ const memoryGroups = response.result?.memories ?? [];
164
+ const scoreGroups = response.result?.scores ?? [];
165
+ const flat = [];
166
+ for (let gi = 0; gi < memoryGroups.length; gi++) {
167
+ const groupObj = memoryGroups[gi];
168
+ const scoreObj = scoreGroups[gi] ?? {};
169
+ for (const [gid, mems] of Object.entries(groupObj)) {
170
+ const scores = scoreObj[gid] ?? [];
171
+ for (let mi = 0; mi < mems.length; mi++) {
172
+ flat.push({ mem: mems[mi], score: scores[mi] ?? 0 });
173
+ }
174
+ }
175
+ }
176
+ return flat.map(({ mem: m, score }, index) => ({
177
+ type: mapMemoryType(m.memory_type),
178
+ uuid: m.id,
179
+ group_id: m.group_id ?? groupId,
180
+ summary: extractContent(m),
181
+ context: `${contextPrefix(m.memory_type)}: ${m.subject ?? ""}`.trim(),
182
+ created_at: m.timestamp ?? m.created_at ?? "",
183
+ score: score || 1.0 - index / Math.max(flat.length, 1),
184
+ }));
185
+ }
186
+ // searchGroups intentionally not implemented — search.ts fan-out handles multi-group.
187
+ // EverMemOS has no native cross-group ranking.
188
+ async enrichSession(params) {
189
+ const meta = {
190
+ group_id: params.groupId,
191
+ user_details: {
192
+ [this.config.defaultSenderId]: {
193
+ role: "user",
194
+ last_message: params.userMsg.slice(0, 200),
195
+ },
196
+ },
197
+ };
198
+ try {
199
+ await this.restCall("POST", "/api/v1/memories/conversation-meta", meta);
200
+ }
201
+ catch {
202
+ // Best-effort — conversation metadata enrichment is non-critical
203
+ }
204
+ }
205
+ async getConversationHistory(sessionId, lastN = 10) {
206
+ const sessionGroup = `session-${sessionId.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
207
+ try {
208
+ const queryParams = {
209
+ group_id: sessionGroup,
210
+ memory_type: "episodic_memory",
211
+ user_id: this.config.defaultSenderId,
212
+ top_k: String(lastN),
213
+ };
214
+ const response = await this.restCall("GET", "/api/v1/memories", undefined, queryParams);
215
+ const memories = response.memories ?? [];
216
+ return memories.map((m) => ({
217
+ query: "",
218
+ answer: m.episode ?? m.summary ?? "",
219
+ created_at: m.created_at,
220
+ }));
221
+ }
222
+ catch {
223
+ return [];
224
+ }
225
+ }
226
+ async healthCheck() {
227
+ try {
228
+ const response = await fetch(`${this.config.endpoint}/health`, { signal: AbortSignal.timeout(5000) });
229
+ return response.ok;
230
+ }
231
+ catch {
232
+ return false;
233
+ }
234
+ }
235
+ async getStatus() {
236
+ return {
237
+ backend: "evermemos",
238
+ endpoint: this.config.endpoint,
239
+ retrieveMethod: this.config.retrieveMethod,
240
+ memoryTypes: this.config.memoryTypes,
241
+ healthy: await this.healthCheck(),
242
+ };
243
+ }
244
+ async deleteGroup(groupId) {
245
+ // LIMITATION: EverMemOS DELETE only soft-deletes MemCells. Derived memories
246
+ // (episodic, foresight, event_log) remain searchable. See:
247
+ // https://github.com/EverMind-AI/EverMemOS/issues/148
248
+ await this.restCall("DELETE", "/api/v1/memories", {
249
+ event_id: "__all__",
250
+ user_id: "__all__",
251
+ group_id: groupId,
252
+ });
253
+ }
254
+ async listGroups() {
255
+ // EverMemOS has no list-groups API
256
+ return [];
257
+ }
258
+ async deleteFragment(uuid, _type) {
259
+ // LIMITATION: EverMemOS DELETE only soft-deletes MemCells, not derived memories.
260
+ // The uuid from search results is a derived memory _id (episodic/foresight/event_log),
261
+ // not a MemCell _id, so the API will return "not found" for most fragment deletes.
262
+ // See: https://github.com/EverMind-AI/EverMemOS/issues/148
263
+ try {
264
+ await this.restCall("DELETE", "/api/v1/memories", {
265
+ event_id: uuid,
266
+ user_id: "__all__",
267
+ group_id: "__all__",
268
+ });
269
+ return true;
270
+ }
271
+ catch {
272
+ return false;
273
+ }
274
+ }
275
+ // --------------------------------------------------------------------------
276
+ // Fragment discovery via trace overlay
277
+ // --------------------------------------------------------------------------
278
+ /**
279
+ * Poll the trace overlay endpoint to discover MongoDB ObjectIds of derived
280
+ * memories (episodic, foresight, event_log) produced from a stored message.
281
+ *
282
+ * Called by index.ts after store() resolves the fragmentId Promise.
283
+ * Returns ObjectIds that match what search results return, enabling
284
+ * fragment-level SpiceDB authorization (involves, share/unshare).
285
+ */
286
+ async discoverFragmentIds(messageId) {
287
+ const pending = this.pendingStores.get(messageId);
288
+ if (!pending)
289
+ return [];
290
+ this.pendingStores.delete(messageId);
291
+ const pollInterval = this.config.discoveryPollIntervalMs ?? 3000;
292
+ const timeout = this.config.discoveryTimeoutMs ?? 120000;
293
+ const maxAttempts = Math.ceil(timeout / pollInterval);
294
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
295
+ await new Promise((r) => setTimeout(r, pollInterval));
296
+ try {
297
+ const trace = await this.restCall("GET", `/api/v1/memories/trace/${encodeURIComponent(messageId)}`);
298
+ if (trace.status === "not_found")
299
+ return [];
300
+ if (trace.status === "complete" && trace.all_ids.length > 0) {
301
+ return trace.all_ids;
302
+ }
303
+ // status === "processing" → keep polling
304
+ }
305
+ catch {
306
+ // Trace endpoint may not be available (older image) — keep trying
307
+ }
308
+ }
309
+ return []; // Timeout — fallback writes the messageId anchor to SpiceDB
310
+ }
311
+ /**
312
+ * Lazy resolution: resolve unmatched SpiceDB anchor UUIDs to actual
313
+ * searchable fragment IDs. Called during recall when viewable fragment IDs
314
+ * from SpiceDB don't match any search results.
315
+ */
316
+ async resolveAnchors(anchorIds) {
317
+ const result = new Map();
318
+ for (const anchor of anchorIds) {
319
+ try {
320
+ const trace = await this.restCall("GET", `/api/v1/memories/trace/${encodeURIComponent(anchor)}`);
321
+ if (trace.status === "complete" && trace.all_ids.length > 0) {
322
+ result.set(anchor, trace.all_ids);
323
+ }
324
+ }
325
+ catch {
326
+ // anchor may not be a message_id (e.g. already an ObjectId) — skip
327
+ }
328
+ }
329
+ return result;
330
+ }
331
+ // --------------------------------------------------------------------------
332
+ // Backend-specific CLI commands
333
+ // --------------------------------------------------------------------------
334
+ registerCliCommands(cmd) {
335
+ cmd
336
+ .command("foresight")
337
+ .description("[evermemos] List foresight entries for a group")
338
+ .option("--group <id>", "Group ID")
339
+ .option("--last <n>", "Number of results", "10")
340
+ .action(async (opts) => {
341
+ const groupId = opts.group ?? this.config.defaultGroupId;
342
+ try {
343
+ const body = {
344
+ query: "",
345
+ group_id: groupId,
346
+ top_k: Number(opts.last),
347
+ retrieve_method: "keyword",
348
+ memory_types: ["foresight"],
349
+ };
350
+ const response = await this.restCall("GET", "/api/v1/memories/search", body);
351
+ // Flatten group-keyed response
352
+ const flat = (response.result?.memories ?? [])
353
+ .flatMap((g) => Object.values(g).flat());
354
+ console.log(JSON.stringify(flat, null, 2));
355
+ }
356
+ catch (err) {
357
+ console.error(`Failed to fetch foresight: ${err instanceof Error ? err.message : String(err)}`);
358
+ }
359
+ });
360
+ cmd
361
+ .command("conversation-meta")
362
+ .description("[evermemos] View conversation metadata for a group")
363
+ .option("--group <id>", "Group ID")
364
+ .action(async (opts) => {
365
+ const groupId = opts.group ?? this.config.defaultGroupId;
366
+ try {
367
+ const queryParams = { group_id: groupId };
368
+ const response = await this.restCall("GET", "/api/v1/memories/conversation-meta", undefined, queryParams);
369
+ console.log(JSON.stringify(response, null, 2));
370
+ }
371
+ catch (err) {
372
+ console.error(`Failed to fetch conversation metadata: ${err instanceof Error ? err.message : String(err)}`);
373
+ }
374
+ });
375
+ cmd
376
+ .command("clear-memories")
377
+ .description("[evermemos] Clear all memories for a group")
378
+ .option("--group <id...>", "Group ID(s)")
379
+ .option("--confirm", "Required safety flag", false)
380
+ .action(async (opts) => {
381
+ if (!opts.confirm) {
382
+ console.log("Destructive operation. Pass --confirm to proceed.");
383
+ return;
384
+ }
385
+ const groups = opts.group ?? [];
386
+ if (groups.length === 0) {
387
+ console.log("No groups specified. Use --group <id> to specify groups.");
388
+ return;
389
+ }
390
+ for (const g of groups) {
391
+ await this.deleteGroup(g);
392
+ console.log(`Cleared group: ${g}`);
393
+ }
394
+ });
395
+ }
396
+ }
397
+ // ============================================================================
398
+ // Backend module exports (used by backends/registry.ts)
399
+ // ============================================================================
400
+ import everMemOSDefaults from "./evermemos.defaults.json" with { type: "json" };
401
+ export const defaults = everMemOSDefaults;
402
+ export function create(config) {
403
+ return new EverMemOSBackend(config);
404
+ }
@@ -7,6 +7,8 @@
7
7
  * 3. Import and register it here
8
8
  */
9
9
  import * as graphiti from "./graphiti.js";
10
+ import * as evermemos from "./evermemos.js";
10
11
  export const backendRegistry = {
11
12
  graphiti,
13
+ evermemos,
12
14
  };
package/dist/config.d.ts CHANGED
@@ -18,6 +18,8 @@ export type RebacMemoryConfig = {
18
18
  subjectId: string;
19
19
  /** Maps agent IDs to their owner person IDs (e.g., Slack user IDs). */
20
20
  identities: Record<string, string>;
21
+ /** Maps group IDs to their owner person IDs (for admin-level sharing). */
22
+ groupOwners: Record<string, string[]>;
21
23
  autoCapture: boolean;
22
24
  autoRecall: boolean;
23
25
  maxCaptureMessages: number;
package/dist/config.js CHANGED
@@ -56,7 +56,7 @@ export const rebacMemoryConfigSchema = {
56
56
  // Top-level allowed keys: shared keys + the backend name key
57
57
  assertAllowedKeys(cfg, [
58
58
  "backend", "spicedb",
59
- "subjectType", "subjectId", "identities",
59
+ "subjectType", "subjectId", "identities", "groupOwners",
60
60
  "autoCapture", "autoRecall", "maxCaptureMessages", "sessionFilter",
61
61
  backendName,
62
62
  ], "openclaw-memory-rebac config");
@@ -79,6 +79,19 @@ export const rebacMemoryConfigSchema = {
79
79
  }
80
80
  }
81
81
  }
82
+ // Parse groupOwners: { "slack-engineering": ["U0123", "U0456"] }
83
+ const groupOwnersRaw = cfg.groupOwners;
84
+ const groupOwners = {};
85
+ if (groupOwnersRaw && typeof groupOwnersRaw === "object" && !Array.isArray(groupOwnersRaw)) {
86
+ for (const [groupId, owners] of Object.entries(groupOwnersRaw)) {
87
+ if (Array.isArray(owners)) {
88
+ groupOwners[groupId] = owners.filter((o) => typeof o === "string" && o.trim() !== "");
89
+ }
90
+ else if (typeof owners === "string" && owners.trim()) {
91
+ groupOwners[groupId] = [owners.trim()];
92
+ }
93
+ }
94
+ }
82
95
  return {
83
96
  backend: backendName,
84
97
  spicedb: {
@@ -94,6 +107,7 @@ export const rebacMemoryConfigSchema = {
94
107
  subjectType,
95
108
  subjectId,
96
109
  identities,
110
+ groupOwners,
97
111
  autoCapture: cfg.autoCapture !== false,
98
112
  autoRecall: cfg.autoRecall !== false,
99
113
  maxCaptureMessages: typeof cfg.maxCaptureMessages === "number" && cfg.maxCaptureMessages > 0
package/dist/index.js CHANGED
@@ -21,7 +21,7 @@ import { join, dirname } from "node:path";
21
21
  import { fileURLToPath } from "node:url";
22
22
  import { rebacMemoryConfigSchema, createBackend, defaultGroupId } from "./config.js";
23
23
  import { SpiceDbClient } from "./spicedb.js";
24
- import { lookupAuthorizedGroups, lookupViewableFragments, lookupFragmentSourceGroups, lookupAgentOwner, writeFragmentRelationships, deleteFragmentRelationships, canDeleteFragment, canWriteToGroup, ensureGroupMembership, } from "./authorization.js";
24
+ import { lookupAuthorizedGroups, lookupViewableFragments, lookupFragmentSourceGroups, lookupAgentOwner, writeFragmentRelationships, deleteFragmentRelationships, canDeleteFragment, canWriteToGroup, ensureGroupMembership, ensureGroupOwnership, canShareFragment, shareFragment, unshareFragment, } from "./authorization.js";
25
25
  import { searchAuthorizedMemories, formatDualResults, } from "./search.js";
26
26
  import { registerCommands } from "./cli.js";
27
27
  // ============================================================================
@@ -218,6 +218,40 @@ const rebacMemoryPlugin = {
218
218
  // Post-filter: only keep results the owner is authorized to view
219
219
  const viewableSet = new Set(newIds);
220
220
  ownerFragmentResults = candidateResults.filter(r => viewableSet.has(r.uuid));
221
+ // Lazy resolution: if post-filter found nothing but we had viewable
222
+ // fragment IDs and candidate results, the IDs may be unresolved
223
+ // anchors (e.g. messageId UUIDs from a timed-out discoverFragmentIds).
224
+ // Try resolving them to actual searchable IDs via the backend.
225
+ if (ownerFragmentResults.length === 0 && candidateResults.length > 0 && backend.resolveAnchors) {
226
+ const resolved = await backend.resolveAnchors(newIds);
227
+ if (resolved.size > 0) {
228
+ const resolvedSet = new Set([...resolved.values()].flat());
229
+ ownerFragmentResults = candidateResults.filter(r => resolvedSet.has(r.uuid));
230
+ // Background: update SpiceDB so future recalls are fast
231
+ if (ownerFragmentResults.length > 0) {
232
+ void (async () => {
233
+ try {
234
+ for (const [, objectIds] of resolved) {
235
+ for (const objId of objectIds) {
236
+ const wt = await writeFragmentRelationships(spicedb, {
237
+ fragmentId: objId,
238
+ groupId: newGroups[0],
239
+ sharedBy: ownerSubject,
240
+ involves: [ownerSubject],
241
+ });
242
+ if (wt)
243
+ state.lastWriteToken = wt;
244
+ }
245
+ }
246
+ api.logger.info(`openclaw-memory-rebac: lazy-resolved ${resolved.size} anchor(s) to ${ownerFragmentResults.length} fragment(s)`);
247
+ }
248
+ catch (lazyErr) {
249
+ api.logger.warn(`openclaw-memory-rebac: lazy SpiceDB update failed: ${String(lazyErr)}`);
250
+ }
251
+ })();
252
+ }
253
+ }
254
+ }
221
255
  }
222
256
  }
223
257
  }
@@ -326,6 +360,11 @@ const rebacMemoryPlugin = {
326
360
  // has a stable episode UUID. Discover extracted fact UUIDs and write
327
361
  // per-fact relationships so that fragment-level permissions (view, delete)
328
362
  // resolve correctly against the IDs returned by memory_recall.
363
+ //
364
+ // Graphiti: polls /episodes/{id}/edges for fact UUIDs.
365
+ // EverMemOS: polls trace overlay for MongoDB ObjectIds of derived memories.
366
+ // If discovery times out, fallback writes the episode UUID itself — lazy
367
+ // resolution at recall time (resolveAnchors) handles the recovery.
329
368
  result.fragmentId
330
369
  .then(async (episodeId) => {
331
370
  const factIds = backend.discoverFragmentIds
@@ -444,6 +483,93 @@ const rebacMemoryPlugin = {
444
483
  };
445
484
  },
446
485
  }), { name: "memory_forget" });
486
+ api.registerTool((ctx) => ({
487
+ name: "memory_share",
488
+ label: "Memory Share",
489
+ description: "Share a specific memory with one or more people/agents, granting them view access. " +
490
+ "Use type-prefixed IDs from memory_recall (e.g. 'fact:UUID', 'chunk:UUID'). " +
491
+ "Only the memory's creator or a group admin can share.",
492
+ parameters: Type.Object({
493
+ id: Type.String({ description: "Memory ID to share (e.g. 'fact:da8650cb-...' or bare UUID)" }),
494
+ share_with: Type.Array(Type.String(), { description: "Person or agent IDs to grant view access" }),
495
+ }),
496
+ async execute(_toolCallId, params) {
497
+ const { id, share_with } = params;
498
+ if (share_with.length === 0) {
499
+ return {
500
+ content: [{ type: "text", text: "No targets specified." }],
501
+ details: { action: "error", id },
502
+ };
503
+ }
504
+ const subject = resolveSubject(ctx.agentId);
505
+ const state = getState(ctx.agentId);
506
+ // Parse type prefix to get bare UUID
507
+ const colonIdx = id.indexOf(":");
508
+ const uuid = colonIdx > 0 && colonIdx < 10 ? id.slice(colonIdx + 1) : id;
509
+ // Check share permission
510
+ const allowed = await canShareFragment(spicedb, subject, uuid, state.lastWriteToken);
511
+ if (!allowed) {
512
+ return {
513
+ content: [{ type: "text", text: `Permission denied: cannot share fragment "${uuid}". Only the creator or a group admin can share.` }],
514
+ details: { action: "denied", id },
515
+ };
516
+ }
517
+ // Write involves relationships for each target
518
+ const targets = share_with.map((targetId) => ({
519
+ type: "person",
520
+ id: targetId,
521
+ }));
522
+ const writeToken = await shareFragment(spicedb, uuid, targets);
523
+ if (writeToken)
524
+ state.lastWriteToken = writeToken;
525
+ return {
526
+ content: [{ type: "text", text: `Shared memory "${uuid}" with: ${share_with.join(", ")}` }],
527
+ details: { action: "shared", id, uuid, targets: share_with },
528
+ };
529
+ },
530
+ }), { name: "memory_share" });
531
+ api.registerTool((ctx) => ({
532
+ name: "memory_unshare",
533
+ label: "Memory Unshare",
534
+ description: "Revoke view access to a specific memory for one or more people/agents. " +
535
+ "Removes the 'involves' relationship. Only the memory's creator or a group admin can unshare.",
536
+ parameters: Type.Object({
537
+ id: Type.String({ description: "Memory ID to unshare (e.g. 'fact:da8650cb-...' or bare UUID)" }),
538
+ revoke_from: Type.Array(Type.String(), { description: "Person or agent IDs to revoke view access from" }),
539
+ }),
540
+ async execute(_toolCallId, params) {
541
+ const { id, revoke_from } = params;
542
+ if (revoke_from.length === 0) {
543
+ return {
544
+ content: [{ type: "text", text: "No targets specified." }],
545
+ details: { action: "error", id },
546
+ };
547
+ }
548
+ const subject = resolveSubject(ctx.agentId);
549
+ const state = getState(ctx.agentId);
550
+ // Parse type prefix to get bare UUID
551
+ const colonIdx = id.indexOf(":");
552
+ const uuid = colonIdx > 0 && colonIdx < 10 ? id.slice(colonIdx + 1) : id;
553
+ // Check share permission (same permission governs unshare)
554
+ const allowed = await canShareFragment(spicedb, subject, uuid, state.lastWriteToken);
555
+ if (!allowed) {
556
+ return {
557
+ content: [{ type: "text", text: `Permission denied: cannot unshare fragment "${uuid}".` }],
558
+ details: { action: "denied", id },
559
+ };
560
+ }
561
+ // Remove involves relationships for each target
562
+ const targets = revoke_from.map((targetId) => ({
563
+ type: "person",
564
+ id: targetId,
565
+ }));
566
+ await unshareFragment(spicedb, uuid, targets);
567
+ return {
568
+ content: [{ type: "text", text: `Revoked access to "${uuid}" from: ${revoke_from.join(", ")}` }],
569
+ details: { action: "unshared", id, uuid, targets: revoke_from },
570
+ };
571
+ },
572
+ }), { name: "memory_unshare" });
447
573
  api.registerTool((ctx) => ({
448
574
  name: "memory_status",
449
575
  label: "Memory Status",
@@ -761,6 +887,21 @@ const rebacMemoryPlugin = {
761
887
  api.logger.warn(`openclaw-memory-rebac: failed to write owner for agent:${agentId}: ${err}`);
762
888
  }
763
889
  }
890
+ // Write group → owner relationships from groupOwners config
891
+ for (const [groupId, ownerIds] of Object.entries(cfg.groupOwners)) {
892
+ for (const ownerId of ownerIds) {
893
+ try {
894
+ const ownerSubject = { type: "person", id: ownerId };
895
+ const token = await ensureGroupOwnership(spicedb, groupId, ownerSubject);
896
+ if (token)
897
+ defaultState.lastWriteToken = token;
898
+ api.logger.info(`openclaw-memory-rebac: set person:${ownerId} as owner of group:${groupId}`);
899
+ }
900
+ catch (err) {
901
+ api.logger.warn(`openclaw-memory-rebac: failed to write owner for group:${groupId}: ${err}`);
902
+ }
903
+ }
904
+ }
764
905
  }
765
906
  api.logger.info(`openclaw-memory-rebac: initialized (backend: ${backend.name} ${backendStatus.healthy ? "OK" : "UNREACHABLE"}, spicedb: ${spicedbOk ? "OK" : "UNREACHABLE"})`);
766
907
  },
@@ -0,0 +1,21 @@
1
+ ###############################################################################
2
+ # openclaw-memory-rebac — Combined stack (EverMemOS + SpiceDB)
3
+ #
4
+ # Brings up the full memory service stack with EverMemOS as the backend:
5
+ # - EverMemOS FastAPI server + MongoDB + Milvus + Elasticsearch + Redis
6
+ # - SpiceDB authorization engine + PostgreSQL
7
+ #
8
+ # Usage:
9
+ # cd docker/evermemos && cp .env.example .env # configure API keys
10
+ # cd docker && docker compose -f docker-compose.evermemos.yml up -d
11
+ #
12
+ # EverMemOS endpoint: http://localhost:1995
13
+ # SpiceDB endpoint: localhost:50051 (insecure)
14
+ #
15
+ # To use Graphiti instead:
16
+ # docker compose up -d # uses docker-compose.yml (Graphiti + SpiceDB)
17
+ ###############################################################################
18
+
19
+ include:
20
+ - path: ./spicedb/docker-compose.yml
21
+ - path: ./evermemos/docker-compose.yml
@@ -6,7 +6,7 @@
6
6
  # - SpiceDB authorization engine + PostgreSQL
7
7
  #
8
8
  # Usage:
9
- # docker compose up -d
9
+ # docker compose -f docker-compose.graphiti.yml up -d
10
10
  #
11
11
  # Graphiti endpoint: http://localhost:8000
12
12
  # SpiceDB endpoint: localhost:50051 (insecure)