@contextableai/openclaw-memory-rebac 0.3.7 → 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.
- 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.d.ts +6 -0
- package/dist/index.js +169 -5
- 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/trace_overlay.py +128 -0
- package/docker/graphiti/Dockerfile +4 -2
- package/docker/graphiti/startup.py +25 -0
- package/docker/spicedb/docker-compose.yml +4 -4
- package/package.json +6 -4
- package/schema.zed +6 -2
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
|
// ============================================================================
|
|
@@ -55,6 +55,30 @@ function isSessionAllowed(sessionKey, filter) {
|
|
|
55
55
|
return true;
|
|
56
56
|
}
|
|
57
57
|
// ============================================================================
|
|
58
|
+
// Content sanitization
|
|
59
|
+
// ============================================================================
|
|
60
|
+
/**
|
|
61
|
+
* Strip OpenClaw envelope metadata from message text before Graphiti ingestion.
|
|
62
|
+
* Removes channel headers, sender/message-id meta lines, and memory injection
|
|
63
|
+
* blocks that would pollute entity extraction.
|
|
64
|
+
*/
|
|
65
|
+
export function stripEnvelopeMetadata(text) {
|
|
66
|
+
let result = text;
|
|
67
|
+
// Strip envelope header: [ChannelName ...metadata...] at start of line
|
|
68
|
+
// Matches: [Telegram Dev Chat +5m 2025-01-02T03:04Z] body
|
|
69
|
+
result = result.replace(/^\[[A-Z][^\]\n]*\]\s*/gm, "");
|
|
70
|
+
// Strip [from: SenderLabel] trailer lines
|
|
71
|
+
result = result.replace(/^\[from:\s*[^\]]*\]\s*$/gm, "");
|
|
72
|
+
// Strip [message_id: ...] hint lines
|
|
73
|
+
result = result.replace(/^\[message_id:\s*[^\]]*\]\s*$/gm, "");
|
|
74
|
+
// Strip memory injection blocks
|
|
75
|
+
result = result.replace(/<relevant-memories>[\s\S]*?<\/relevant-memories>/g, "");
|
|
76
|
+
result = result.replace(/<memory-tools>[\s\S]*?<\/memory-tools>/g, "");
|
|
77
|
+
// Collapse excess blank lines and trim
|
|
78
|
+
result = result.replace(/\n{3,}/g, "\n\n").trim();
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
// ============================================================================
|
|
58
82
|
// Plugin Definition
|
|
59
83
|
// ============================================================================
|
|
60
84
|
const rebacMemoryPlugin = {
|
|
@@ -194,6 +218,40 @@ const rebacMemoryPlugin = {
|
|
|
194
218
|
// Post-filter: only keep results the owner is authorized to view
|
|
195
219
|
const viewableSet = new Set(newIds);
|
|
196
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
|
+
}
|
|
197
255
|
}
|
|
198
256
|
}
|
|
199
257
|
}
|
|
@@ -302,6 +360,11 @@ const rebacMemoryPlugin = {
|
|
|
302
360
|
// has a stable episode UUID. Discover extracted fact UUIDs and write
|
|
303
361
|
// per-fact relationships so that fragment-level permissions (view, delete)
|
|
304
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.
|
|
305
368
|
result.fragmentId
|
|
306
369
|
.then(async (episodeId) => {
|
|
307
370
|
const factIds = backend.discoverFragmentIds
|
|
@@ -420,6 +483,93 @@ const rebacMemoryPlugin = {
|
|
|
420
483
|
};
|
|
421
484
|
},
|
|
422
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" });
|
|
423
573
|
api.registerTool((ctx) => ({
|
|
424
574
|
name: "memory_status",
|
|
425
575
|
label: "Memory Status",
|
|
@@ -566,9 +716,8 @@ const rebacMemoryPlugin = {
|
|
|
566
716
|
}
|
|
567
717
|
text = textParts.join("\n");
|
|
568
718
|
}
|
|
569
|
-
// Strip injected
|
|
570
|
-
text = text
|
|
571
|
-
text = text.replace(/<memory-tools>[\s\S]*?<\/memory-tools>/g, "").trim();
|
|
719
|
+
// Strip envelope metadata and injected blocks — keep the user's actual content
|
|
720
|
+
text = stripEnvelopeMetadata(text);
|
|
572
721
|
if (!text || text.length < 5)
|
|
573
722
|
continue;
|
|
574
723
|
const roleLabel = role === "user" ? "User" : "Assistant";
|
|
@@ -660,7 +809,7 @@ const rebacMemoryPlugin = {
|
|
|
660
809
|
}
|
|
661
810
|
return "";
|
|
662
811
|
};
|
|
663
|
-
const userMsg = extractText(lastUserMsg);
|
|
812
|
+
const userMsg = stripEnvelopeMetadata(extractText(lastUserMsg));
|
|
664
813
|
const assistantMsg = extractText(lastAssistMsg);
|
|
665
814
|
if (userMsg && assistantMsg) {
|
|
666
815
|
backend.enrichSession({
|
|
@@ -738,6 +887,21 @@ const rebacMemoryPlugin = {
|
|
|
738
887
|
api.logger.warn(`openclaw-memory-rebac: failed to write owner for agent:${agentId}: ${err}`);
|
|
739
888
|
}
|
|
740
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
|
+
}
|
|
741
905
|
}
|
|
742
906
|
api.logger.info(`openclaw-memory-rebac: initialized (backend: ${backend.name} ${backendStatus.healthy ? "OK" : "UNREACHABLE"}, spicedb: ${spicedbOk ? "OK" : "UNREACHABLE"})`);
|
|
743
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
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# =============================================================================
|
|
2
|
+
# EverMemOS Configuration
|
|
3
|
+
# =============================================================================
|
|
4
|
+
#
|
|
5
|
+
# Copy this file to .env and fill in your API keys.
|
|
6
|
+
# Database connection settings are pre-configured for Docker — no changes needed.
|
|
7
|
+
#
|
|
8
|
+
# cp .env.example .env
|
|
9
|
+
#
|
|
10
|
+
# SECURITY: Never commit .env to version control.
|
|
11
|
+
# =============================================================================
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ===================
|
|
15
|
+
# LLM Configuration (required)
|
|
16
|
+
# ===================
|
|
17
|
+
# Used for memory extraction (MemCell pipeline).
|
|
18
|
+
|
|
19
|
+
LLM_PROVIDER=openai
|
|
20
|
+
LLM_MODEL=gpt-4o-mini
|
|
21
|
+
LLM_BASE_URL=https://api.openai.com/v1
|
|
22
|
+
LLM_API_KEY=sk-your-key-here
|
|
23
|
+
LLM_TEMPERATURE=0.3
|
|
24
|
+
LLM_MAX_TOKENS=32768
|
|
25
|
+
|
|
26
|
+
# Alternative: OpenRouter (uncomment and set key)
|
|
27
|
+
# LLM_MODEL=x-ai/grok-4-fast
|
|
28
|
+
# LLM_BASE_URL=https://openrouter.ai/api/v1
|
|
29
|
+
# LLM_API_KEY=sk-or-v1-your-key-here
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ===================
|
|
33
|
+
# Vectorize / Embedding (required)
|
|
34
|
+
# ===================
|
|
35
|
+
# Used for semantic search. Supports vLLM (self-hosted) or DeepInfra (commercial).
|
|
36
|
+
|
|
37
|
+
VECTORIZE_PROVIDER=deepinfra
|
|
38
|
+
VECTORIZE_API_KEY=your-deepinfra-key-here
|
|
39
|
+
VECTORIZE_BASE_URL=https://api.deepinfra.com/v1/openai
|
|
40
|
+
VECTORIZE_MODEL=Qwen/Qwen3-Embedding-4B
|
|
41
|
+
VECTORIZE_DIMENSIONS=1024
|
|
42
|
+
VECTORIZE_TIMEOUT=30
|
|
43
|
+
VECTORIZE_MAX_RETRIES=3
|
|
44
|
+
VECTORIZE_BATCH_SIZE=10
|
|
45
|
+
VECTORIZE_MAX_CONCURRENT=5
|
|
46
|
+
VECTORIZE_ENCODING_FORMAT=float
|
|
47
|
+
|
|
48
|
+
# Alternative: self-hosted vLLM (uncomment)
|
|
49
|
+
# VECTORIZE_PROVIDER=vllm
|
|
50
|
+
# VECTORIZE_API_KEY=EMPTY
|
|
51
|
+
# VECTORIZE_BASE_URL=http://host.docker.internal:8000/v1
|
|
52
|
+
|
|
53
|
+
# Optional: fallback vectorize provider
|
|
54
|
+
# VECTORIZE_FALLBACK_PROVIDER=deepinfra
|
|
55
|
+
# VECTORIZE_FALLBACK_API_KEY=your-fallback-key
|
|
56
|
+
# VECTORIZE_FALLBACK_BASE_URL=https://api.deepinfra.com/v1/openai
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ===================
|
|
60
|
+
# Rerank (required)
|
|
61
|
+
# ===================
|
|
62
|
+
# Used for search result reranking. Supports vLLM or DeepInfra.
|
|
63
|
+
|
|
64
|
+
RERANK_PROVIDER=deepinfra
|
|
65
|
+
RERANK_API_KEY=your-deepinfra-key-here
|
|
66
|
+
RERANK_BASE_URL=https://api.deepinfra.com/v1/inference
|
|
67
|
+
RERANK_MODEL=Qwen/Qwen3-Reranker-4B
|
|
68
|
+
RERANK_TIMEOUT=30
|
|
69
|
+
RERANK_MAX_RETRIES=3
|
|
70
|
+
RERANK_BATCH_SIZE=10
|
|
71
|
+
RERANK_MAX_CONCURRENT=5
|
|
72
|
+
|
|
73
|
+
# Optional: fallback rerank provider
|
|
74
|
+
# RERANK_FALLBACK_PROVIDER=none
|
|
75
|
+
# RERANK_FALLBACK_API_KEY=
|
|
76
|
+
# RERANK_FALLBACK_BASE_URL=
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ===================
|
|
80
|
+
# Application Settings
|
|
81
|
+
# ===================
|
|
82
|
+
|
|
83
|
+
LOG_LEVEL=INFO
|
|
84
|
+
ENV=dev
|
|
85
|
+
MEMORY_LANGUAGE=en
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
###############################################################################
|
|
2
|
+
# EverMemOS All-in-One Container
|
|
3
|
+
#
|
|
4
|
+
# Bundles all required services into a single container:
|
|
5
|
+
# - MongoDB 7.0 (port 27017)
|
|
6
|
+
# - Elasticsearch 8 (port 9200, internal only)
|
|
7
|
+
# - Milvus standalone (port 19530, with embedded etcd)
|
|
8
|
+
# - Redis 7 (port 6379, internal only)
|
|
9
|
+
# - EverMemOS API (port 1995, exposed)
|
|
10
|
+
#
|
|
11
|
+
# Managed by supervisord. All data stored under /data.
|
|
12
|
+
#
|
|
13
|
+
# Pinned to EverMemOS commit SHA for reproducibility.
|
|
14
|
+
# Update EVERMEMOS_COMMIT to upgrade.
|
|
15
|
+
#
|
|
16
|
+
# Build:
|
|
17
|
+
# docker compose build evermemos
|
|
18
|
+
#
|
|
19
|
+
# Override version at build time:
|
|
20
|
+
# docker compose build --build-arg EVERMEMOS_COMMIT=<sha> evermemos
|
|
21
|
+
###############################################################################
|
|
22
|
+
|
|
23
|
+
# Stage 1: Extract Milvus binaries from official image
|
|
24
|
+
FROM milvusdb/milvus:v2.5.2 AS milvus-source
|
|
25
|
+
|
|
26
|
+
# Stage 2: All-in-one container
|
|
27
|
+
FROM ubuntu:22.04
|
|
28
|
+
|
|
29
|
+
ARG EVERMEMOS_COMMIT=3c9a2d02460748310c7707048f7ed6b6bb078ab4
|
|
30
|
+
ARG TARGETARCH
|
|
31
|
+
|
|
32
|
+
ENV DEBIAN_FRONTEND=noninteractive
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Base packages + supervisord
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
38
|
+
supervisor curl wget gnupg git ca-certificates \
|
|
39
|
+
python3 python3-pip python3-venv \
|
|
40
|
+
redis-server \
|
|
41
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# MongoDB 7.0
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
RUN curl -fsSL https://www.mongodb.org/static/pgp/server-7.0.asc | \
|
|
47
|
+
gpg --dearmor -o /usr/share/keyrings/mongodb-server-7.0.gpg && \
|
|
48
|
+
echo "deb [ signed-by=/usr/share/keyrings/mongodb-server-7.0.gpg ] \
|
|
49
|
+
https://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/7.0 multiverse" \
|
|
50
|
+
> /etc/apt/sources.list.d/mongodb-org-7.0.list && \
|
|
51
|
+
apt-get update && \
|
|
52
|
+
apt-get install -y --no-install-recommends mongodb-org && \
|
|
53
|
+
rm -rf /var/lib/apt/lists/*
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Elasticsearch 8.11 (no security, single-node)
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
RUN curl -fsSL https://artifacts.elastic.co/GPG-KEY-elasticsearch | \
|
|
59
|
+
gpg --dearmor -o /usr/share/keyrings/elasticsearch.gpg && \
|
|
60
|
+
echo "deb [signed-by=/usr/share/keyrings/elasticsearch.gpg] \
|
|
61
|
+
https://artifacts.elastic.co/packages/8.x/apt stable main" \
|
|
62
|
+
> /etc/apt/sources.list.d/elasticsearch.list && \
|
|
63
|
+
apt-get update && \
|
|
64
|
+
apt-get install -y --no-install-recommends elasticsearch && \
|
|
65
|
+
rm -rf /var/lib/apt/lists/*
|
|
66
|
+
|
|
67
|
+
# ES config: single-node, no security, limit heap
|
|
68
|
+
RUN sed -i 's/xpack.security.enabled: true/xpack.security.enabled: false/' /etc/elasticsearch/elasticsearch.yml && \
|
|
69
|
+
sed -i '/cluster.initial_master_nodes/d' /etc/elasticsearch/elasticsearch.yml && \
|
|
70
|
+
echo "discovery.type: single-node" >> /etc/elasticsearch/elasticsearch.yml && \
|
|
71
|
+
echo "network.host: 127.0.0.1" >> /etc/elasticsearch/elasticsearch.yml && \
|
|
72
|
+
echo "-Xms512m" > /etc/elasticsearch/jvm.options.d/heap.options && \
|
|
73
|
+
echo "-Xmx512m" >> /etc/elasticsearch/jvm.options.d/heap.options
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Milvus standalone (copied from official Docker image)
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
COPY --from=milvus-source /milvus /opt/milvus
|
|
79
|
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
80
|
+
libgomp1 libtbb2 libopenblas-dev libaio1 && \
|
|
81
|
+
rm -rf /var/lib/apt/lists/*
|
|
82
|
+
|
|
83
|
+
# Milvus config for embedded etcd + local storage (no minio)
|
|
84
|
+
RUN mkdir -p /data/milvus /opt/milvus/configs && \
|
|
85
|
+
printf '%s\n' \
|
|
86
|
+
'listen-client-urls: http://0.0.0.0:2379' \
|
|
87
|
+
'advertise-client-urls: http://0.0.0.0:2379' \
|
|
88
|
+
'quota-backend-bytes: 4294967296' \
|
|
89
|
+
'auto-compaction-mode: revision' \
|
|
90
|
+
"auto-compaction-retention: '1000'" \
|
|
91
|
+
> /opt/milvus/configs/embedEtcd.yaml
|
|
92
|
+
|
|
93
|
+
ENV LD_LIBRARY_PATH=/opt/milvus/lib:$LD_LIBRARY_PATH
|
|
94
|
+
ENV PATH=/opt/milvus/bin:$PATH
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# EverMemOS (Python app from source)
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
RUN pip install --no-cache-dir uv
|
|
100
|
+
|
|
101
|
+
WORKDIR /app
|
|
102
|
+
RUN git clone https://github.com/EverMind-AI/EverMemOS.git . && \
|
|
103
|
+
git checkout "$EVERMEMOS_COMMIT" && \
|
|
104
|
+
uv sync --no-dev
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
# Data directories
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
RUN mkdir -p /data/mongodb /data/elasticsearch /data/milvus /data/redis
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
# Supervisord configuration
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
COPY supervisord.conf /etc/supervisor/conf.d/evermemos.conf
|
|
115
|
+
COPY entrypoint.sh /entrypoint.sh
|
|
116
|
+
RUN chmod +x /entrypoint.sh
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
# Trace overlay: read-only endpoint for message_id → derived memory ObjectIds
|
|
120
|
+
# Same pattern as Graphiti's startup.py overlay — extends our image, not upstream.
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
COPY trace_overlay.py /app/trace_overlay.py
|
|
123
|
+
RUN echo 'from trace_overlay import router as _trace_router; app.include_router(_trace_router)' >> /app/src/app.py
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# Healthcheck — verify the EverMemOS API responds
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
HEALTHCHECK --interval=15s --timeout=10s --retries=10 --start-period=60s \
|
|
129
|
+
CMD curl -sf http://localhost:1995/health || exit 1
|
|
130
|
+
|
|
131
|
+
EXPOSE 1995
|
|
132
|
+
|
|
133
|
+
CMD ["/entrypoint.sh"]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
###############################################################################
|
|
2
|
+
# openclaw-memory-rebac — EverMemOS memory backend (all-in-one container)
|
|
3
|
+
#
|
|
4
|
+
# Single container bundles: MongoDB, Elasticsearch, Milvus, Redis, EverMemOS.
|
|
5
|
+
# Managed by supervisord internally.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# cp .env.example .env # configure LLM, vectorize, rerank API keys
|
|
9
|
+
# docker compose up -d # builds image on first run (~5 min)
|
|
10
|
+
#
|
|
11
|
+
# EverMemOS endpoint: http://localhost:1995
|
|
12
|
+
#
|
|
13
|
+
# Resource requirements: ~4 GB RAM (Elasticsearch + Milvus are memory-heavy)
|
|
14
|
+
###############################################################################
|
|
15
|
+
|
|
16
|
+
name: openclaw-evermemos
|
|
17
|
+
|
|
18
|
+
services:
|
|
19
|
+
evermemos:
|
|
20
|
+
build:
|
|
21
|
+
context: .
|
|
22
|
+
args:
|
|
23
|
+
EVERMEMOS_COMMIT: 3c9a2d02460748310c7707048f7ed6b6bb078ab4
|
|
24
|
+
restart: unless-stopped
|
|
25
|
+
ports:
|
|
26
|
+
- "1995:1995"
|
|
27
|
+
env_file:
|
|
28
|
+
- .env
|
|
29
|
+
environment:
|
|
30
|
+
# All backing services run inside the container on localhost
|
|
31
|
+
MONGODB_HOST: 127.0.0.1
|
|
32
|
+
MONGODB_PORT: "27017"
|
|
33
|
+
MONGODB_USERNAME: ""
|
|
34
|
+
MONGODB_PASSWORD: ""
|
|
35
|
+
MONGODB_DATABASE: memsys
|
|
36
|
+
MONGODB_URI_PARAMS: "socketTimeoutMS=15000"
|
|
37
|
+
ES_HOSTS: http://127.0.0.1:9200
|
|
38
|
+
MILVUS_HOST: 127.0.0.1
|
|
39
|
+
MILVUS_PORT: "19530"
|
|
40
|
+
REDIS_HOST: 127.0.0.1
|
|
41
|
+
REDIS_PORT: "6379"
|
|
42
|
+
volumes:
|
|
43
|
+
- evermemos_data:/data
|
|
44
|
+
healthcheck:
|
|
45
|
+
test: ["CMD", "curl", "-sf", "http://localhost:1995/health"]
|
|
46
|
+
interval: 15s
|
|
47
|
+
timeout: 10s
|
|
48
|
+
retries: 10
|
|
49
|
+
start_period: 60s
|
|
50
|
+
|
|
51
|
+
volumes:
|
|
52
|
+
evermemos_data:
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Read-only tracing endpoint: message_id → derived memory ObjectIds.
|
|
3
|
+
|
|
4
|
+
Mounted on the EverMemOS FastAPI app at startup via Dockerfile append.
|
|
5
|
+
This is NOT a monkeypatch — it adds a new read-only endpoint to our Docker
|
|
6
|
+
image, following the same pattern as the Graphiti startup.py overlay.
|
|
7
|
+
|
|
8
|
+
Endpoint:
|
|
9
|
+
GET /api/v1/memories/trace/{message_id}
|
|
10
|
+
|
|
11
|
+
Returns the MongoDB ObjectIds of all derived memories (episodic, foresight,
|
|
12
|
+
event_log) produced from a given ingestion message. Used by the
|
|
13
|
+
openclaw-memory-rebac plugin to write SpiceDB fragment relationships
|
|
14
|
+
against the actual IDs that appear in search results.
|
|
15
|
+
|
|
16
|
+
Response:
|
|
17
|
+
{
|
|
18
|
+
"message_id": "uuid-123",
|
|
19
|
+
"status": "complete|processing|not_found",
|
|
20
|
+
"memcell_ids": ["ObjectId-A"],
|
|
21
|
+
"derived_memories": {
|
|
22
|
+
"episodic_memory": ["ObjectId-B", ...],
|
|
23
|
+
"foresight": ["ObjectId-D", ...],
|
|
24
|
+
"event_log": ["ObjectId-E", ...],
|
|
25
|
+
},
|
|
26
|
+
"all_ids": ["ObjectId-B", "ObjectId-D", "ObjectId-E", ...]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
Uses pymongo (synchronous) since motor is not installed in EverMemOS.
|
|
30
|
+
Blocking MongoDB calls are wrapped in asyncio.to_thread for FastAPI compat.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import asyncio
|
|
34
|
+
import os
|
|
35
|
+
from pymongo import MongoClient
|
|
36
|
+
from fastapi import APIRouter
|
|
37
|
+
|
|
38
|
+
router = APIRouter(tags=["trace"])
|
|
39
|
+
|
|
40
|
+
_db = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _get_db():
|
|
44
|
+
global _db
|
|
45
|
+
if _db is None:
|
|
46
|
+
host = os.environ.get("MONGODB_HOST", "127.0.0.1")
|
|
47
|
+
port = os.environ.get("MONGODB_PORT", "27017")
|
|
48
|
+
username = os.environ.get("MONGODB_USERNAME", "")
|
|
49
|
+
password = os.environ.get("MONGODB_PASSWORD", "")
|
|
50
|
+
db_name = os.environ.get("MONGODB_DATABASE", "memsys")
|
|
51
|
+
if username and password:
|
|
52
|
+
mongo_uri = f"mongodb://{username}:{password}@{host}:{port}"
|
|
53
|
+
else:
|
|
54
|
+
mongo_uri = f"mongodb://{host}:{port}"
|
|
55
|
+
client = MongoClient(mongo_uri)
|
|
56
|
+
_db = client[db_name]
|
|
57
|
+
return _db
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _trace_sync(message_id: str) -> dict:
|
|
61
|
+
"""Synchronous MongoDB trace — run via asyncio.to_thread."""
|
|
62
|
+
db = _get_db()
|
|
63
|
+
|
|
64
|
+
# Step 1: Check if message_id exists in memory_request_logs
|
|
65
|
+
log_entry = db.memory_request_logs.find_one({"message_id": message_id})
|
|
66
|
+
if not log_entry:
|
|
67
|
+
return {
|
|
68
|
+
"message_id": message_id,
|
|
69
|
+
"status": "not_found",
|
|
70
|
+
"memcell_ids": [],
|
|
71
|
+
"derived_memories": {},
|
|
72
|
+
"all_ids": [],
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# Step 2: Find memcell that contains this message_id in its original_data
|
|
76
|
+
# The message_id is stored at: original_data[*].messages[*].extend.message_id
|
|
77
|
+
memcell = db.memcells.find_one(
|
|
78
|
+
{"original_data.messages.extend.message_id": message_id}
|
|
79
|
+
)
|
|
80
|
+
if not memcell:
|
|
81
|
+
# Log exists but memcell not yet created — still in boundary detection
|
|
82
|
+
return {
|
|
83
|
+
"message_id": message_id,
|
|
84
|
+
"status": "processing",
|
|
85
|
+
"memcell_ids": [],
|
|
86
|
+
"derived_memories": {},
|
|
87
|
+
"all_ids": [],
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
memcell_id = str(memcell["_id"])
|
|
91
|
+
|
|
92
|
+
# Step 3: Query derived memory collections
|
|
93
|
+
derived = {
|
|
94
|
+
"episodic_memory": [],
|
|
95
|
+
"foresight": [],
|
|
96
|
+
"event_log": [],
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for doc in db.episodic_memories.find(
|
|
100
|
+
{"memcell_event_id_list": memcell_id}, {"_id": 1}
|
|
101
|
+
):
|
|
102
|
+
derived["episodic_memory"].append(str(doc["_id"]))
|
|
103
|
+
|
|
104
|
+
for doc in db.foresight_records.find(
|
|
105
|
+
{"parent_id": memcell_id, "parent_type": "memcell"}, {"_id": 1}
|
|
106
|
+
):
|
|
107
|
+
derived["foresight"].append(str(doc["_id"]))
|
|
108
|
+
|
|
109
|
+
for doc in db.event_log_records.find(
|
|
110
|
+
{"parent_id": memcell_id, "parent_type": "memcell"}, {"_id": 1}
|
|
111
|
+
):
|
|
112
|
+
derived["event_log"].append(str(doc["_id"]))
|
|
113
|
+
|
|
114
|
+
all_ids = [id_ for ids in derived.values() for id_ in ids]
|
|
115
|
+
status = "complete" if all_ids else "processing"
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
"message_id": message_id,
|
|
119
|
+
"status": status,
|
|
120
|
+
"memcell_ids": [memcell_id],
|
|
121
|
+
"derived_memories": derived,
|
|
122
|
+
"all_ids": all_ids,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@router.get("/api/v1/memories/trace/{message_id}")
|
|
127
|
+
async def trace_message(message_id: str):
|
|
128
|
+
return await asyncio.to_thread(_trace_sync, message_id)
|