@contextableai/openclaw-memory-rebac 0.4.2 → 0.5.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 CHANGED
@@ -1,65 +1,60 @@
1
1
  # @contextableai/openclaw-memory-rebac
2
2
 
3
- Two-layer memory plugin for OpenClaw: **SpiceDB** for authorization, **pluggable backends** for knowledge storage.
3
+ Composite memory plugin for OpenClaw: **Graphiti** knowledge graph (primary) with optional **EverMemOS** liminal memory, authorized by **SpiceDB** ReBAC.
4
4
 
5
- Agents remember conversations as structured knowledge. SpiceDB enforces who can read and write which memories — authorization lives at the data layer, not in prompts. The backend is swappable: start with Graphiti's knowledge graph today, add new storage engines tomorrow.
5
+ Agents remember conversations as structured knowledge. SpiceDB enforces who can read and write which memories — authorization lives at the data layer, not in prompts. Two operating modes: **unified** (Graphiti handles everything) or **hybrid** (Graphiti for tools, EverMemOS for conversational hooks, with a promotion bridge).
6
6
 
7
7
  ## Architecture
8
8
 
9
9
  ```
10
- ┌──────────────────────────────────────────────────┐
11
- │ OpenClaw Agent │
12
- │ │
13
- │ memory_recall ──► SpiceDB ──► Backend Search │
14
- memory_store ──► SpiceDB ──► Backend Write │
15
- memory_forget ──► SpiceDB ──► Backend Delete │
16
- auto-recall ──► SpiceDB ──► Backend Search │
17
- auto-capture ──► SpiceDB ──► Backend Write │
18
- └──────────────────────────────────────────────────┘
19
-
20
- ┌────▼────┐ ┌─────▼─────┐
21
- SpiceDB │ │ Backend │
22
- │ (authz) │ │ (storage) │
23
- └─────────┘ └───────────┘
10
+ Unified mode (default):
11
+ Tools + hooks → Graphiti → SpiceDB auth on all fragments.
12
+
13
+ Hybrid mode (liminal: "evermemos"):
14
+ ├── Tools Graphiti (primary, ReBAC authorized)
15
+ ├── memory_recall, memory_store, memory_forget
16
+ ├── memory_share / unshare
17
+ └── memory_promote (reads EverMemOS, writes Graphiti)
18
+ ├── Hooks → EverMemOS only (liminal)
19
+ ├── before_agent_start → EverMemOS auto-recall (recent context)
20
+ │ └── agent_end → EverMemOS auto-capture (no SpiceDB writes)
21
+ └── SpiceDB (Graphiti fragments only)
24
22
  ```
25
23
 
26
- **SpiceDB** determines which `group_id`s a subject (agent or person) can access. The **backend** stores and searches memories scoped to those groups. Authorization is enforced before any read or write reaches the backend.
24
+ **SpiceDB** determines which `group_id`s a subject (agent or person) can access. The **primary backend** (Graphiti) stores and searches memories scoped to those groups. In hybrid mode, the **liminal backend** (EverMemOS) handles conversational auto-recall and auto-capture without SpiceDB authorization — important memories are promoted to Graphiti via the `memory_promote` tool.
27
25
 
28
- ### Why Two Layers?
26
+ ### Why This Design?
29
27
 
30
- Most memory systems bundle authorization with storage — you get dataset isolation, but it's tied to the storage engine's auth model. That creates conflicts when you need external authorization (like SpiceDB) or want to swap backends without re-implementing access control.
31
-
32
- openclaw-memory-rebac separates these concerns:
28
+ Most memory systems bundle authorization with storage. openclaw-memory-rebac separates these concerns:
33
29
  - **SpiceDB** owns the authorization model (relationships, permissions, consistency)
34
- - **Backends** own the storage model (indexing, search, extraction)
35
- - The plugin orchestrates both authorization check first, then backend operation
30
+ - **Graphiti** owns the curated knowledge graph (entities, facts, structured retrieval)
31
+ - **EverMemOS** (optional) owns conversational context (episodic memory, foresight, profiles)
32
+ - The plugin orchestrates all three — routing tools to Graphiti with SpiceDB authorization, and hooks to the liminal backend
36
33
 
37
- This means you can change your storage engine without touching authorization, and vice versa.
34
+ In unified mode, Graphiti handles everything (same as a single-backend plugin). In hybrid mode, EverMemOS captures conversational context automatically while Graphiti remains the authoritative knowledge store accessed via tools.
38
35
 
39
36
  ## Backends
40
37
 
41
- ### Graphiti (default)
38
+ ### Graphiti (primary)
42
39
 
43
- [Graphiti](https://github.com/getzep/graphiti) builds a knowledge graph from conversations. It extracts entities, facts, and relationships, storing them in Neo4j for structured retrieval.
40
+ [Graphiti](https://github.com/getzep/graphiti) builds a knowledge graph from conversations. It extracts entities, facts, and relationships, storing them in Neo4j for structured retrieval. Always serves as the primary backend for tools and SpiceDB-authorized operations.
44
41
 
45
42
  - **Storage**: Neo4j graph database
46
43
  - **Transport**: Direct REST API to Graphiti FastAPI server
47
44
  - **Extraction**: LLM-powered entity and relationship extraction (~300 embedding calls per episode)
48
45
  - **Search**: Dual-mode — searches both nodes (entities) and facts (relationships) in parallel
49
46
  - **Docker image**: Custom image (`docker/graphiti/`) with per-component LLM/embedder/reranker configuration, BGE reranker support, and runtime patches for local-model compatibility
50
- - **Best for**: Rich entity-relationship extraction, structured knowledge
51
47
 
52
- ### EverMemOS
48
+ ### EverMemOS (liminal)
53
49
 
54
- [EverMemOS](https://github.com/EverMind-AI/EverMemOS) is a memory system with MemCell boundary detection and parallel LLM extraction. It produces richer memory types episodic memories, user profiles, foresight (predictions), and event logs.
50
+ [EverMemOS](https://github.com/EverMind-AI/EverMemOS) is a conversational memory system with MemCell boundary detection and parallel LLM extraction. In hybrid mode, it serves as the **liminal backend** handling auto-recall and auto-capture hooks without SpiceDB authorization.
55
51
 
56
52
  - **Storage**: MongoDB + Milvus (vector) + Elasticsearch (keyword)
57
53
  - **Transport**: REST API to EverMemOS FastAPI server (port 1995)
58
- - **Extraction**: MemCell pipeline — automatic boundary detection, then parallel LLM extraction of multiple memory types
54
+ - **Extraction**: MemCell pipeline — automatic boundary detection, then parallel LLM extraction of episodic memories, profiles, foresight, and event logs
59
55
  - **Search**: Hybrid (vector + keyword + reranking), configurable via `retrieveMethod`
60
56
  - **Docker image**: Built from source (`docker/evermemos/Dockerfile`), pinned to upstream release tag (v1.1.0)
61
- - **Best for**: Rich memory type diversity, foresight extraction, hybrid search
62
- - **Limitation**: No `involves` at ingestion — use `memory_share` for post-hoc cross-group sharing
57
+ - **Role**: Liminal only — not used for tool-based operations. Important memories are promoted to Graphiti via `memory_promote`.
63
58
 
64
59
  #### EverMemOS-specific config
65
60
 
@@ -92,7 +87,7 @@ Then restart the gateway. On first start, the plugin automatically:
92
87
 
93
88
  ### 1. Start Infrastructure
94
89
 
95
- **Option A: Graphiti backend (default)**
90
+ **Unified mode (Graphiti only default)**
96
91
 
97
92
  ```bash
98
93
  cd docker
@@ -103,20 +98,23 @@ docker compose -f docker-compose.graphiti.yml up -d
103
98
 
104
99
  This starts: Neo4j (7687), Graphiti (8000), PostgreSQL (5432), SpiceDB (50051).
105
100
 
106
- **Option B: EverMemOS backend**
101
+ **Hybrid mode (Graphiti + EverMemOS)**
107
102
 
108
103
  ```bash
104
+ # Start Graphiti stack
105
+ cd docker
106
+ cp graphiti/.env.example graphiti/.env
107
+ docker compose -f docker-compose.graphiti.yml up -d
108
+
109
+ # Start EverMemOS stack (shares SpiceDB)
109
110
  cd docker/evermemos
110
111
  cp .env.example .env
111
112
  # Edit .env — set LLM_API_KEY, VECTORIZE_API_KEY, RERANK_API_KEY
112
-
113
- cd docker
113
+ cd ..
114
114
  docker compose -f docker-compose.evermemos.yml up -d
115
115
  ```
116
116
 
117
- This starts: EverMemOS all-in-one container (1995), PostgreSQL (5432), SpiceDB (50051). First run builds the image from source (~5 min).
118
-
119
- Both options share the same SpiceDB instance with the same authorization schema.
117
+ This starts both stacks. EverMemOS first run builds from source (~5 min). Both share the same SpiceDB instance.
120
118
 
121
119
  ### 2. Restart the Gateway
122
120
 
@@ -135,7 +133,7 @@ rebac-mem add-member family dad --type person
135
133
 
136
134
  ## Tools
137
135
 
138
- The plugin registers four tools available to the agent:
136
+ The plugin registers the following tools (plus `memory_promote` in hybrid mode):
139
137
 
140
138
  ### memory_recall
141
139
 
@@ -177,24 +175,39 @@ Delete a memory fragment. Requires `delete` permission (only the subject who sto
177
175
 
178
176
  Check the health of the backend and SpiceDB services. No parameters.
179
177
 
178
+ ### memory_promote (hybrid mode only)
179
+
180
+ Promote memories from the liminal backend (EverMemOS) into the primary knowledge graph (Graphiti) with full SpiceDB authorization.
181
+
182
+ | Parameter | Type | Default | Description |
183
+ |-----------|------|---------|-------------|
184
+ | `query` | string | *required* | Search query to find memories to promote |
185
+ | `groupId` | string | configured default | Target group in the knowledge graph |
186
+ | `limit` | number | 3 | Max memories to promote |
187
+
188
+ Searches EverMemOS for matching memories, stores each into Graphiti, and writes SpiceDB fragment relationships. This bridges short-term conversational context into the long-term authorized knowledge graph.
189
+
180
190
  ## Automatic Behaviors
181
191
 
182
192
  ### Auto-Recall
183
193
 
184
- When enabled (default: `true`), the plugin searches relevant memories before each agent turn and injects them into the conversation context as `<relevant-memories>` blocks.
194
+ When enabled (default: `true`), the plugin searches relevant memories before each agent turn and injects them into context.
195
+
196
+ **Unified mode**: Searches Graphiti via SpiceDB-authorized groups. Injects as `<relevant-memories>` blocks. Searches up to 5 long-term and 3 session memories per turn, deduplicates session results against long-term.
185
197
 
186
- - Searches up to 5 long-term memories and 3 session memories per turn
187
- - Deduplicates session results against long-term results
188
- - Only triggers when the user prompt is at least 5 characters
198
+ **Hybrid mode**: Searches EverMemOS only (no SpiceDB). Injects as `<recent-context>` blocks. The agent accesses Graphiti knowledge on-demand via the `memory_recall` tool.
199
+
200
+ Both modes only trigger when the user prompt is at least 5 characters.
189
201
 
190
202
  ### Auto-Capture
191
203
 
192
- When enabled (default: `true`), the plugin captures the last N messages from each completed agent turn and stores them as a batch episode.
204
+ When enabled (default: `true`), the plugin captures the last N messages from each completed agent turn.
205
+
206
+ **Unified mode**: Stores to Graphiti with full SpiceDB fragment authorization. Uses custom extraction instructions.
193
207
 
194
- - Captures up to `maxCaptureMessages` messages (default: 10)
195
- - Stores to the current session group by default
196
- - Skips messages shorter than 5 characters and injected context blocks
197
- - Uses custom extraction instructions for entity/fact extraction
208
+ **Hybrid mode**: Stores to EverMemOS only (fire-and-forget, no SpiceDB writes). EverMemOS handles extraction internally via its MemCell pipeline. Important memories are promoted to Graphiti via `memory_promote`.
209
+
210
+ Both modes capture up to `maxCaptureMessages` messages (default: 10), skip messages shorter than 5 characters, and skip injected context blocks.
198
211
 
199
212
  ### Session Filtering
200
213
 
@@ -308,7 +321,8 @@ Agents without an `identities` entry (like service agents) are not linked to any
308
321
 
309
322
  | Key | Type | Default | Description |
310
323
  |-----|------|---------|-------------|
311
- | `backend` | string | `graphiti` | Storage backend (`graphiti`) |
324
+ | `backend` | string | `graphiti` | Primary backend for tools and SpiceDB-authorized operations |
325
+ | `liminal` | string | *(same as backend)* | Liminal backend for auto-recall/capture hooks. Set to `"evermemos"` for hybrid mode |
312
326
  | `spicedb.endpoint` | string | `localhost:50051` | SpiceDB gRPC endpoint |
313
327
  | `spicedb.token` | string | *required* | SpiceDB pre-shared key (supports `${ENV_VAR}`) |
314
328
  | `spicedb.insecure` | boolean | `true` | Allow insecure gRPC (for localhost dev) |
@@ -417,36 +431,47 @@ npx tsx bin/rebac-mem.ts status
417
431
 
418
432
  OpenClaw has an exclusive `memory` slot — only one memory plugin is active at a time:
419
433
 
434
+ **Unified mode** (Graphiti only):
435
+
436
+ ```json
437
+ {
438
+ "plugins": {
439
+ "slots": { "memory": "openclaw-memory-rebac" },
440
+ "entries": {
441
+ "openclaw-memory-rebac": {
442
+ "enabled": true,
443
+ "config": {
444
+ "backend": "graphiti",
445
+ "spicedb": { "endpoint": "localhost:50051", "token": "dev_token", "insecure": true },
446
+ "graphiti": { "endpoint": "http://localhost:8000", "defaultGroupId": "main" },
447
+ "subjectType": "agent",
448
+ "subjectId": "my-agent",
449
+ "identities": { "my-agent": "U0123ABC" }
450
+ }
451
+ }
452
+ }
453
+ }
454
+ }
455
+ ```
456
+
457
+ **Hybrid mode** (Graphiti + EverMemOS):
458
+
420
459
  ```json
421
460
  {
422
461
  "plugins": {
423
- "slots": {
424
- "memory": "openclaw-memory-rebac"
425
- },
462
+ "slots": { "memory": "openclaw-memory-rebac" },
426
463
  "entries": {
427
464
  "openclaw-memory-rebac": {
428
465
  "enabled": true,
429
466
  "config": {
430
467
  "backend": "graphiti",
431
- "spicedb": {
432
- "endpoint": "localhost:50051",
433
- "token": "dev_token",
434
- "insecure": true
435
- },
436
- "graphiti": {
437
- "endpoint": "http://localhost:8000",
438
- "defaultGroupId": "main"
439
- },
468
+ "liminal": "evermemos",
469
+ "spicedb": { "endpoint": "localhost:50051", "token": "dev_token", "insecure": true },
470
+ "graphiti": { "endpoint": "http://localhost:8000", "defaultGroupId": "main" },
471
+ "evermemos": { "endpoint": "http://localhost:1995" },
440
472
  "subjectType": "agent",
441
473
  "subjectId": "my-agent",
442
- "identities": {
443
- "my-agent": "U0123ABC"
444
- },
445
- "autoCapture": true,
446
- "autoRecall": true,
447
- "sessionFilter": {
448
- "excludePatterns": ["cron"]
449
- }
474
+ "identities": { "my-agent": "U0123ABC" }
450
475
  }
451
476
  }
452
477
  }
@@ -479,17 +504,17 @@ rebac-mem import --include-sessions
479
504
 
480
505
  ## Docker Compose
481
506
 
482
- The `docker/` directory contains modular Docker Compose stacks. Two top-level compose files select which memory backend to pair with SpiceDB:
507
+ The `docker/` directory contains modular Docker Compose stacks:
483
508
 
484
509
  ```bash
485
- # Graphiti + SpiceDB
510
+ # Unified mode: Graphiti + SpiceDB
486
511
  cd docker && docker compose -f docker-compose.graphiti.yml up -d
487
512
 
488
- # EverMemOS + SpiceDB
513
+ # Hybrid mode: add EverMemOS (shares SpiceDB with Graphiti stack)
489
514
  cd docker && docker compose -f docker-compose.evermemos.yml up -d
490
515
  ```
491
516
 
492
- Both share the same SpiceDB sub-stack — same authorization schema, same permissions model.
517
+ Both stacks share the same SpiceDB sub-stack — same authorization schema, same permissions model.
493
518
 
494
519
  ### Graphiti Stack (`docker/graphiti/`)
495
520
 
@@ -556,9 +581,9 @@ OPENCLAW_LIVE_TEST=1 npm run test:e2e
556
581
  ### Project Structure
557
582
 
558
583
  ```
559
- ├── index.ts # Plugin entry: tools, hooks, CLI, service
560
- ├── backend.ts # MemoryBackend interface (all backends implement this)
561
- ├── config.ts # Config schema, validation, backend factory
584
+ ├── index.ts # Plugin entry: tools, hooks, composite routing
585
+ ├── backend.ts # MemoryBackend interface
586
+ ├── config.ts # Config schema, validation, backend + liminal factory
562
587
  ├── cli.ts # Shared CLI commands (plugin + standalone)
563
588
  ├── search.ts # Multi-group parallel search, dedup, formatting
564
589
  ├── authorization.ts # Authorization logic (SpiceDB operations)
@@ -589,7 +614,7 @@ OPENCLAW_LIVE_TEST=1 npm run test:e2e
589
614
  │ │ └── .env.example # LLM, vectorize, rerank API keys
590
615
  │ └── spicedb/
591
616
  │ └── docker-compose.yml
592
- ├── *.test.ts # Unit tests (178)
617
+ ├── *.test.ts # Unit tests (186)
593
618
  ├── e2e.test.ts # Graphiti E2E tests (14, live services)
594
619
  ├── e2e-backend.test.ts # Backend-agnostic E2E contract (13)
595
620
  ├── e2e-evermemos.test.ts # EverMemOS-specific E2E (7)
package/dist/backend.d.ts CHANGED
@@ -30,14 +30,11 @@ export type SearchResult = {
30
30
  * Returned by store(). The fragmentId resolves to the UUID that will be
31
31
  * registered in SpiceDB.
32
32
  *
33
- * - Graphiti: Resolves once the server has processed the episode (polled in the background).
34
- * - EverMemOS: Resolves immediately to our generated message_id UUID. This anchor is used
35
- * for share/involves SpiceDB relationships. Note: search result IDs (MongoDB ObjectIds)
36
- * differ from this anchor, so involves-based cross-group recall post-filtering won't
37
- * match — group-level access handles the common path.
33
+ * Graphiti: Resolves once the server has processed the episode (polled in the background).
34
+ * EverMemOS (liminal mode): Resolves immediately to a generated message_id UUID.
38
35
  *
39
36
  * index.ts chains SpiceDB writeFragmentRelationships() to this Promise,
40
- * so it always fires at the right time.
37
+ * so it always fires at the right time (primary/unified mode only).
41
38
  */
42
39
  export type StoreResult = {
43
40
  fragmentId: Promise<string>;
@@ -142,20 +139,6 @@ export interface MemoryBackend {
142
139
  * Optional: not all backends separate episodes from fragments.
143
140
  */
144
141
  discoverFragmentIds?(episodeId: string): Promise<string[]>;
145
- /**
146
- * Resolve anchor IDs (e.g. message UUIDs written to SpiceDB during store)
147
- * to actual searchable fragment IDs (e.g. MongoDB ObjectIds).
148
- *
149
- * Called during recall when viewable fragment IDs from SpiceDB don't match
150
- * any search results — indicates the anchors were written before extraction
151
- * completed (discoverFragmentIds timed out).
152
- *
153
- * Returns a Map of anchor → resolved fragment IDs. Only anchors that
154
- * successfully resolved are included; unresolvable anchors are omitted.
155
- *
156
- * Optional: backends where store IDs match search IDs don't need this.
157
- */
158
- resolveAnchors?(anchorIds: string[]): Promise<Map<string, string[]>>;
159
142
  /**
160
143
  * Register backend-specific CLI subcommands onto the shared `rebac-mem` command.
161
144
  * Called once during CLI setup. Backend may register any commands it needs.
@@ -5,16 +5,12 @@
5
5
  * Messages are processed through the MemCell pipeline: boundary detection →
6
6
  * parallel LLM extraction of episodic memories, foresight, event logs, and profiles.
7
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).
8
+ * In the composite plugin architecture, EverMemOS serves as the **liminal** backend:
9
+ * hook-driven auto-recall and auto-capture with no SpiceDB fragment authorization.
10
+ * Graphiti remains the primary backend for tool-based operations with full ReBAC.
14
11
  *
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.
12
+ * store() returns immediately with a generated message_id.
13
+ * With @timeout_to_background(), store may return 202 Accepted for background processing.
18
14
  */
19
15
  import type { Command } from "commander";
20
16
  import type { MemoryBackend, SearchResult, StoreResult, ConversationTurn, BackendDataset } from "../backend.js";
@@ -25,18 +21,11 @@ export type EverMemOSConfig = {
25
21
  retrieveMethod: string;
26
22
  memoryTypes: string[];
27
23
  defaultSenderId: string;
28
- discoveryPollIntervalMs: number;
29
- discoveryTimeoutMs: number;
30
24
  };
31
25
  export declare class EverMemOSBackend implements MemoryBackend {
32
26
  private readonly config;
33
27
  readonly name = "evermemos";
34
28
  private readonly requestTimeoutMs;
35
- /**
36
- * Tracks pending stores: messageId → groupId.
37
- * Populated by store(), consumed by discoverFragmentIds().
38
- */
39
- private readonly pendingStores;
40
29
  constructor(config: EverMemOSConfig);
41
30
  private restCall;
42
31
  store(params: {
@@ -63,21 +52,6 @@ export declare class EverMemOSBackend implements MemoryBackend {
63
52
  deleteGroup(groupId: string): Promise<void>;
64
53
  listGroups(): Promise<BackendDataset[]>;
65
54
  deleteFragment(uuid: string, _type?: string): Promise<boolean>;
66
- /**
67
- * Poll the trace overlay endpoint to discover MongoDB ObjectIds of derived
68
- * memories (episodic, foresight, event_log) produced from a stored message.
69
- *
70
- * Called by index.ts after store() resolves the fragmentId Promise.
71
- * Returns ObjectIds that match what search results return, enabling
72
- * fragment-level SpiceDB authorization (involves, share/unshare).
73
- */
74
- discoverFragmentIds(messageId: string): Promise<string[]>;
75
- /**
76
- * Lazy resolution: resolve unmatched SpiceDB anchor UUIDs to actual
77
- * searchable fragment IDs. Called during recall when viewable fragment IDs
78
- * from SpiceDB don't match any search results.
79
- */
80
- resolveAnchors(anchorIds: string[]): Promise<Map<string, string[]>>;
81
55
  registerCliCommands(cmd: Command): void;
82
56
  }
83
57
  export declare const defaults: Record<string, unknown>;
@@ -4,7 +4,5 @@
4
4
  "requestTimeoutMs": 30000,
5
5
  "retrieveMethod": "hybrid",
6
6
  "memoryTypes": ["episodic_memory", "profile", "foresight", "event_log"],
7
- "defaultSenderId": "system",
8
- "discoveryPollIntervalMs": 3000,
9
- "discoveryTimeoutMs": 120000
7
+ "defaultSenderId": "system"
10
8
  }
@@ -5,16 +5,12 @@
5
5
  * Messages are processed through the MemCell pipeline: boundary detection →
6
6
  * parallel LLM extraction of episodic memories, foresight, event logs, and profiles.
7
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).
8
+ * In the composite plugin architecture, EverMemOS serves as the **liminal** backend:
9
+ * hook-driven auto-recall and auto-capture with no SpiceDB fragment authorization.
10
+ * Graphiti remains the primary backend for tool-based operations with full ReBAC.
14
11
  *
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.
12
+ * store() returns immediately with a generated message_id.
13
+ * With @timeout_to_background(), store may return 202 Accepted for background processing.
18
14
  */
19
15
  import { randomUUID } from "node:crypto";
20
16
  import { request as undiciRequest } from "undici";
@@ -66,11 +62,6 @@ export class EverMemOSBackend {
66
62
  config;
67
63
  name = "evermemos";
68
64
  requestTimeoutMs;
69
- /**
70
- * Tracks pending stores: messageId → groupId.
71
- * Populated by store(), consumed by discoverFragmentIds().
72
- */
73
- pendingStores = new Map();
74
65
  constructor(config) {
75
66
  this.config = config;
76
67
  this.requestTimeoutMs = config.requestTimeoutMs ?? 30000;
@@ -140,11 +131,6 @@ export class EverMemOSBackend {
140
131
  await this.restCall("POST", "/api/v1/memories", request);
141
132
  // EverMemOS processes messages asynchronously through its MemCell pipeline.
142
133
  // 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
134
  return { fragmentId: Promise.resolve(messageId) };
149
135
  }
150
136
  async searchGroup(params) {
@@ -273,62 +259,6 @@ export class EverMemOSBackend {
273
259
  }
274
260
  }
275
261
  // --------------------------------------------------------------------------
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
262
  // Backend-specific CLI commands
333
263
  // --------------------------------------------------------------------------
334
264
  registerCliCommands(cmd) {
package/dist/config.d.ts CHANGED
@@ -8,12 +8,18 @@
8
8
  import type { MemoryBackend } from "./backend.js";
9
9
  export type RebacMemoryConfig = {
10
10
  backend: string;
11
+ /** Liminal backend name for hook-driven auto-recall/capture. Defaults to `backend`. */
12
+ liminal: string;
13
+ /** True when liminal differs from backend (hybrid mode: separate backends for hooks vs tools). */
14
+ isHybrid: boolean;
11
15
  spicedb: {
12
16
  endpoint: string;
13
17
  token: string;
14
18
  insecure: boolean;
15
19
  };
16
20
  backendConfig: Record<string, unknown>;
21
+ /** Liminal backend config (same as backendConfig when unified, separate in hybrid mode). */
22
+ liminalConfig: Record<string, unknown>;
17
23
  subjectType: "agent" | "person";
18
24
  subjectId: string;
19
25
  /** Maps agent IDs to their owner person IDs (e.g., Slack user IDs). */
@@ -32,10 +38,16 @@ export declare const rebacMemoryConfigSchema: {
32
38
  parse(value: unknown): RebacMemoryConfig;
33
39
  };
34
40
  /**
35
- * Instantiate the configured MemoryBackend from the parsed config.
41
+ * Instantiate the primary MemoryBackend (used for tools + SpiceDB auth).
36
42
  * Call this once during plugin registration — the returned backend is stateful.
37
43
  */
38
44
  export declare function createBackend(cfg: RebacMemoryConfig): MemoryBackend;
45
+ /**
46
+ * Instantiate the liminal MemoryBackend (used for hook-driven auto-recall/capture).
47
+ * In unified mode (liminal === backend), callers should reuse the primary instance.
48
+ * In hybrid mode, this creates a separate instance from the liminal backend's config.
49
+ */
50
+ export declare function createLiminalBackend(cfg: RebacMemoryConfig): MemoryBackend;
39
51
  /**
40
52
  * Return the default group ID for the active backend.
41
53
  */
package/dist/config.js CHANGED
@@ -53,20 +53,40 @@ export const rebacMemoryConfigSchema = {
53
53
  const entry = backendRegistry[backendName];
54
54
  if (!entry)
55
55
  throw new Error(`Unknown backend: "${backendName}"`);
56
- // Top-level allowed keys: shared keys + the backend name key
57
- assertAllowedKeys(cfg, [
58
- "backend", "spicedb",
56
+ // Liminal backend: defaults to the primary backend (unified mode).
57
+ // Set to a different backend name for hybrid mode (e.g., "evermemos").
58
+ const liminalName = typeof cfg.liminal === "string" ? cfg.liminal : backendName;
59
+ const liminalEntry = backendRegistry[liminalName];
60
+ if (!liminalEntry)
61
+ throw new Error(`Unknown liminal backend: "${liminalName}"`);
62
+ const isHybrid = liminalName !== backendName;
63
+ // Top-level allowed keys: shared keys + backend name keys
64
+ const allowedKeys = [
65
+ "backend", "liminal", "spicedb",
59
66
  "subjectType", "subjectId", "identities", "groupOwners",
60
67
  "autoCapture", "autoRecall", "maxCaptureMessages", "sessionFilter",
61
68
  backendName,
62
- ], "openclaw-memory-rebac config");
69
+ ];
70
+ if (isHybrid)
71
+ allowedKeys.push(liminalName);
72
+ assertAllowedKeys(cfg, allowedKeys, "openclaw-memory-rebac config");
63
73
  // SpiceDB config (shared)
64
74
  const spicedb = cfg.spicedb ?? {};
65
75
  assertAllowedKeys(spicedb, ["endpoint", "token", "insecure"], "spicedb config");
66
- // Backend config: user overrides merged over JSON defaults
76
+ // Primary backend config: user overrides merged over JSON defaults
67
77
  const backendRaw = cfg[backendName] ?? {};
68
78
  assertAllowedKeys(backendRaw, Object.keys(entry.defaults), `${backendName} config`);
69
79
  const backendConfig = { ...entry.defaults, ...backendRaw };
80
+ // Liminal backend config: same as primary in unified mode, separate in hybrid
81
+ let liminalConfig;
82
+ if (isHybrid) {
83
+ const liminalRaw = cfg[liminalName] ?? {};
84
+ assertAllowedKeys(liminalRaw, Object.keys(liminalEntry.defaults), `${liminalName} config`);
85
+ liminalConfig = { ...liminalEntry.defaults, ...liminalRaw };
86
+ }
87
+ else {
88
+ liminalConfig = backendConfig;
89
+ }
70
90
  const subjectType = cfg.subjectType === "person" ? "person" : pluginDefaults.subjectType;
71
91
  const subjectId = typeof cfg.subjectId === "string" ? resolveEnvVars(cfg.subjectId) : pluginDefaults.subjectId;
72
92
  // Parse identities: { "main": "U0123ABC", "work": "U0456DEF" }
@@ -94,6 +114,8 @@ export const rebacMemoryConfigSchema = {
94
114
  }
95
115
  return {
96
116
  backend: backendName,
117
+ liminal: liminalName,
118
+ isHybrid,
97
119
  spicedb: {
98
120
  endpoint: typeof spicedb.endpoint === "string"
99
121
  ? spicedb.endpoint
@@ -104,6 +126,7 @@ export const rebacMemoryConfigSchema = {
104
126
  : pluginDefaults.spicedb.insecure,
105
127
  },
106
128
  backendConfig,
129
+ liminalConfig,
107
130
  subjectType,
108
131
  subjectId,
109
132
  identities,
@@ -121,7 +144,7 @@ export const rebacMemoryConfigSchema = {
121
144
  // Backend factory
122
145
  // ============================================================================
123
146
  /**
124
- * Instantiate the configured MemoryBackend from the parsed config.
147
+ * Instantiate the primary MemoryBackend (used for tools + SpiceDB auth).
125
148
  * Call this once during plugin registration — the returned backend is stateful.
126
149
  */
127
150
  export function createBackend(cfg) {
@@ -130,6 +153,17 @@ export function createBackend(cfg) {
130
153
  throw new Error(`Unknown backend: "${cfg.backend}"`);
131
154
  return entry.create(cfg.backendConfig);
132
155
  }
156
+ /**
157
+ * Instantiate the liminal MemoryBackend (used for hook-driven auto-recall/capture).
158
+ * In unified mode (liminal === backend), callers should reuse the primary instance.
159
+ * In hybrid mode, this creates a separate instance from the liminal backend's config.
160
+ */
161
+ export function createLiminalBackend(cfg) {
162
+ const entry = backendRegistry[cfg.liminal];
163
+ if (!entry)
164
+ throw new Error(`Unknown liminal backend: "${cfg.liminal}"`);
165
+ return entry.create(cfg.liminalConfig);
166
+ }
133
167
  /**
134
168
  * Return the default group ID for the active backend.
135
169
  */
package/dist/index.js CHANGED
@@ -19,7 +19,7 @@ import { Type } from "@sinclair/typebox";
19
19
  import { readFileSync } from "node:fs";
20
20
  import { join, dirname } from "node:path";
21
21
  import { fileURLToPath } from "node:url";
22
- import { rebacMemoryConfigSchema, createBackend, defaultGroupId } from "./config.js";
22
+ import { rebacMemoryConfigSchema, createBackend, createLiminalBackend, defaultGroupId } from "./config.js";
23
23
  import { SpiceDbClient } from "./spicedb.js";
24
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";
@@ -95,8 +95,13 @@ const rebacMemoryPlugin = {
95
95
  ' "config": { "spicedb": { "token": "<your-preshared-key>", "insecure": true } }');
96
96
  }
97
97
  const backend = createBackend(cfg);
98
+ const liminal = cfg.isHybrid ? createLiminalBackend(cfg) : backend;
99
+ const isHybrid = cfg.isHybrid;
98
100
  const spicedb = new SpiceDbClient(cfg.spicedb);
99
101
  const backendDefaultGroupId = defaultGroupId(cfg);
102
+ const liminalDefaultGroupId = isHybrid
103
+ ? (cfg.liminalConfig["defaultGroupId"] ?? "main")
104
+ : backendDefaultGroupId;
100
105
  // Suppress transient gRPC rejections from @grpc/grpc-js during connection setup
101
106
  const grpcRejectionHandler = (reason) => {
102
107
  const msg = String(reason);
@@ -140,7 +145,8 @@ const rebacMemoryPlugin = {
140
145
  api.registerTool((ctx) => ({
141
146
  name: "memory_recall",
142
147
  label: "Memory Recall",
143
- description: "Search through memories using the knowledge graph. Returns results the current user is authorized to see. Supports session, long-term, or combined scope. REQUIRES a search query.",
148
+ description: "Search through memories using the knowledge graph. Returns results the current user is authorized to see. " +
149
+ "Supports session, long-term, or combined scope. REQUIRES a search query.",
144
150
  parameters: Type.Object({
145
151
  query: Type.String({ description: "REQUIRED: Search query for semantic matching" }),
146
152
  limit: Type.Optional(Type.Number({ description: "Max results (default: 10)" })),
@@ -218,40 +224,6 @@ const rebacMemoryPlugin = {
218
224
  // Post-filter: only keep results the owner is authorized to view
219
225
  const viewableSet = new Set(newIds);
220
226
  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
- }
255
227
  }
256
228
  }
257
229
  }
@@ -605,6 +577,71 @@ const rebacMemoryPlugin = {
605
577
  };
606
578
  },
607
579
  }), { name: "memory_status" });
580
+ // memory_promote: only available in hybrid mode (separate liminal backend)
581
+ if (isHybrid) {
582
+ api.registerTool((ctx) => ({
583
+ name: "memory_promote",
584
+ label: "Promote Memory",
585
+ description: "Promote memories from recent conversational context (liminal) into the long-term " +
586
+ "knowledge graph with full ReBAC authorization. Searches the liminal backend and " +
587
+ "stores matching results into the primary backend.",
588
+ parameters: Type.Object({
589
+ query: Type.String({ description: "Search query to find memories to promote" }),
590
+ groupId: Type.Optional(Type.String({ description: "Target group in the knowledge graph (default: primary default group)" })),
591
+ limit: Type.Optional(Type.Number({ description: "Max memories to promote (default: 3)" })),
592
+ }),
593
+ async execute(_toolCallId, params) {
594
+ const { query, groupId, limit: promoteLimit = 3 } = params;
595
+ const subject = resolveSubject(ctx.agentId);
596
+ const state = getState(ctx.agentId);
597
+ const targetGroup = groupId ?? backendDefaultGroupId;
598
+ // 1. Search the liminal backend
599
+ const liminalResults = await searchAuthorizedMemories(liminal, {
600
+ query,
601
+ groupIds: [liminalDefaultGroupId],
602
+ limit: promoteLimit,
603
+ });
604
+ if (liminalResults.length === 0) {
605
+ return {
606
+ content: [{ type: "text", text: "No matching memories found in recent context to promote." }],
607
+ details: { promoted: 0 },
608
+ };
609
+ }
610
+ // 2. Check write access to the target group
611
+ const canWrite = await canWriteToGroup(spicedb, subject, targetGroup, state.lastWriteToken);
612
+ if (!canWrite) {
613
+ return {
614
+ content: [{ type: "text", text: `Not authorized to write to group "${targetGroup}".` }],
615
+ details: { promoted: 0, error: "authorization_denied" },
616
+ };
617
+ }
618
+ // 3. Store each memory into the primary backend with full SpiceDB auth
619
+ const promoted = [];
620
+ for (const mem of liminalResults) {
621
+ const storeResult = await backend.store({
622
+ content: mem.summary,
623
+ groupId: targetGroup,
624
+ sourceDescription: `promoted from liminal (${mem.context || mem.type})`,
625
+ });
626
+ const fragmentId = await storeResult.fragmentId;
627
+ const writeToken = await writeFragmentRelationships(spicedb, {
628
+ fragmentId,
629
+ groupId: targetGroup,
630
+ sharedBy: subject,
631
+ });
632
+ if (writeToken)
633
+ state.lastWriteToken = writeToken;
634
+ promoted.push(fragmentId);
635
+ }
636
+ const summary = `Promoted ${promoted.length} memory/memories from recent context to "${targetGroup}":\n` +
637
+ liminalResults.map((r, i) => ` ${i + 1}. [${r.type}] ${r.summary.slice(0, 100)}...`).join("\n");
638
+ return {
639
+ content: [{ type: "text", text: summary }],
640
+ details: { promoted: promoted.length, fragmentIds: promoted, targetGroup },
641
+ };
642
+ },
643
+ }), { name: "memory_promote" });
644
+ }
608
645
  // ========================================================================
609
646
  // CLI Commands
610
647
  // ========================================================================
@@ -635,6 +672,35 @@ const rebacMemoryPlugin = {
635
672
  if (!event.prompt || event.prompt.length < 5)
636
673
  return;
637
674
  try {
675
+ if (isHybrid) {
676
+ // Hybrid mode: query liminal backend only (no SpiceDB auth).
677
+ // Graphiti knowledge is accessed on-demand via memory_recall tool.
678
+ const autoRecallLimit = 8;
679
+ const liminalResults = await searchAuthorizedMemories(liminal, {
680
+ query: event.prompt,
681
+ groupIds: [liminalDefaultGroupId],
682
+ limit: autoRecallLimit,
683
+ sessionId: state.sessionId,
684
+ });
685
+ const toolHint = "<memory-tools>\n" +
686
+ "You have memory tools:\n" +
687
+ "- memory_recall: Use this BEFORE saying you don't know or remember something. Search the long-term knowledge graph for facts, preferences, people, decisions.\n" +
688
+ "- memory_store: Save important new information to the knowledge graph.\n" +
689
+ "- memory_share: Share a specific memory with other people/agents by ID.\n" +
690
+ "- memory_unshare: Revoke view access to a memory from people/agents.\n" +
691
+ "- memory_promote: Promote recent memories into the long-term knowledge graph.\n" +
692
+ "</memory-tools>";
693
+ if (liminalResults.length === 0)
694
+ return { prependContext: toolHint };
695
+ const memoryContext = liminalResults
696
+ .map((r) => `[${r.type}:${r.uuid}] ${r.summary}${r.context ? ` (${r.context})` : ""}`)
697
+ .join("\n");
698
+ api.logger.info?.(`openclaw-memory-rebac: injecting ${liminalResults.length} liminal memories`);
699
+ return {
700
+ prependContext: `${toolHint}\n\n<recent-context>\nRecent conversational memory:\n${memoryContext}\n</recent-context>`,
701
+ };
702
+ }
703
+ // Unified mode: query primary backend via SpiceDB-authorized groups (unchanged)
638
704
  const authorizedGroups = await lookupAuthorizedGroups(spicedb, subject, state.lastWriteToken);
639
705
  const longTermGroups = authorizedGroups.filter((g) => !isSessionGroup(g));
640
706
  const sessionGroups = authorizedGroups.filter(isSessionGroup);
@@ -658,9 +724,11 @@ const rebacMemoryPlugin = {
658
724
  const sessionResults = allResults.filter((r) => sessionGroupSet.has(r.group_id));
659
725
  const totalCount = allResults.length;
660
726
  const toolHint = "<memory-tools>\n" +
661
- "You have knowledge-graph memory tools. Use them proactively:\n" +
662
- "- memory_recall: Search for facts, preferences, people, decisions, or past context. Use this BEFORE saying you don't know or remember something.\n" +
727
+ "You have memory tools:\n" +
728
+ "- memory_recall: Use this BEFORE saying you don't know or remember something. Search for facts, preferences, people, decisions.\n" +
663
729
  "- memory_store: Save important new information (preferences, decisions, facts about people).\n" +
730
+ "- memory_share: Share a specific memory with other people/agents by ID.\n" +
731
+ "- memory_unshare: Revoke view access to a memory from people/agents.\n" +
664
732
  "</memory-tools>";
665
733
  if (totalCount === 0)
666
734
  return { prependContext: toolHint };
@@ -727,6 +795,53 @@ const rebacMemoryPlugin = {
727
795
  if (conversationLines.length === 0)
728
796
  return;
729
797
  const episodeBody = conversationLines.join("\n");
798
+ if (isHybrid) {
799
+ // Hybrid mode: store to liminal backend only (fire-and-forget, no SpiceDB).
800
+ // Primary backend (Graphiti) gets data only via explicit memory_store or memory_promote.
801
+ const liminalGroupId = liminalDefaultGroupId;
802
+ await liminal.store({
803
+ content: episodeBody,
804
+ groupId: liminalGroupId,
805
+ sourceDescription: "auto-captured conversation",
806
+ });
807
+ // Liminal session enrichment
808
+ if (liminal.enrichSession && state.sessionId) {
809
+ const lastUserMsg = [...event.messages]
810
+ .reverse()
811
+ .find((m) => m.role === "user");
812
+ const lastAssistMsg = [...event.messages]
813
+ .reverse()
814
+ .find((m) => m.role === "assistant");
815
+ const extractText = (m) => {
816
+ if (!m || typeof m !== "object")
817
+ return "";
818
+ const obj = m;
819
+ if (typeof obj.content === "string")
820
+ return obj.content;
821
+ if (Array.isArray(obj.content)) {
822
+ return obj.content
823
+ .filter((b) => typeof b === "object" && b !== null &&
824
+ b.type === "text")
825
+ .map((b) => b.text)
826
+ .join("\n");
827
+ }
828
+ return "";
829
+ };
830
+ const userMsg = stripEnvelopeMetadata(extractText(lastUserMsg));
831
+ const assistantMsg = extractText(lastAssistMsg);
832
+ if (userMsg && assistantMsg) {
833
+ liminal.enrichSession({
834
+ sessionId: state.sessionId,
835
+ groupId: liminalGroupId,
836
+ userMsg,
837
+ assistantMsg,
838
+ }).catch(() => { });
839
+ }
840
+ }
841
+ api.logger.info(`openclaw-memory-rebac: auto-captured ${conversationLines.length} messages to liminal (${liminalGroupId})`);
842
+ return;
843
+ }
844
+ // Unified mode: store to primary with full SpiceDB write chain
730
845
  const targetGroupId = state.sessionId
731
846
  ? sessionGroupId(state.sessionId)
732
847
  : backendDefaultGroupId;
@@ -839,10 +954,10 @@ const rebacMemoryPlugin = {
839
954
  try {
840
955
  const existing = await spicedb.readSchema();
841
956
  spicedbOk = true;
842
- if (!existing || !existing.includes("memory_fragment")) {
843
- api.logger.info("openclaw-memory-rebac: writing SpiceDB schema (first run)");
844
- const schemaPath = join(dirname(fileURLToPath(import.meta.url)), "schema.zed");
845
- const schema = readFileSync(schemaPath, "utf-8");
957
+ const schemaPath = join(dirname(fileURLToPath(import.meta.url)), "schema.zed");
958
+ const schema = readFileSync(schemaPath, "utf-8");
959
+ if (!existing || existing.trim() !== schema.trim()) {
960
+ api.logger.info("openclaw-memory-rebac: writing SpiceDB schema (first run or update)");
846
961
  await spicedb.writeSchema(schema);
847
962
  api.logger.info("openclaw-memory-rebac: SpiceDB schema written successfully");
848
963
  }
@@ -115,13 +115,6 @@ COPY supervisord.conf /etc/supervisor/conf.d/evermemos.conf
115
115
  COPY entrypoint.sh /entrypoint.sh
116
116
  RUN chmod +x /entrypoint.sh
117
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
118
  # ---------------------------------------------------------------------------
126
119
  # Healthcheck — verify the EverMemOS API responds
127
120
  # ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@contextableai/openclaw-memory-rebac",
3
- "version": "0.4.2",
4
- "description": "OpenClaw two-layer memory plugin: SpiceDB ReBAC authorization + pluggable storage (Graphiti, EverMemOS)",
3
+ "version": "0.5.1",
4
+ "description": "OpenClaw composite memory plugin: SpiceDB ReBAC authorization with Graphiti knowledge graph (primary) and optional EverMemOS liminal memory",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -1,128 +0,0 @@
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)