@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/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 memory/tool blocks — keep the user's actual content
570
- text = text.replace(/<relevant-memories>[\s\S]*?<\/relevant-memories>/g, "").trim();
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
@@ -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)
@@ -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)