@contextableai/openclaw-memory-rebac 0.3.8 → 0.4.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 +81 -15
- package/dist/authorization.d.ts +20 -0
- package/dist/authorization.js +60 -0
- package/dist/backend.d.ts +18 -0
- package/dist/backends/evermemos.d.ts +84 -0
- package/dist/backends/evermemos.defaults.json +10 -0
- package/dist/backends/evermemos.js +404 -0
- package/dist/backends/registry.js +2 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +15 -1
- package/dist/index.js +142 -1
- package/docker/docker-compose.evermemos.yml +21 -0
- package/docker/{docker-compose.yml → docker-compose.graphiti.yml} +1 -1
- package/docker/evermemos/.env.example +85 -0
- package/docker/evermemos/Dockerfile +133 -0
- package/docker/evermemos/docker-compose.yml +52 -0
- package/docker/evermemos/entrypoint.sh +8 -0
- package/docker/evermemos/supervisord.conf +74 -0
- package/docker/evermemos/trace_overlay.py +128 -0
- package/docker/spicedb/docker-compose.yml +4 -4
- package/package.json +8 -4
- package/schema.zed +6 -2
|
@@ -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
|
+
}
|
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
|