@contextableai/openclaw-memory-rebac 0.4.1 → 0.5.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 +103 -78
- package/dist/backend.d.ts +3 -20
- package/dist/backends/evermemos.d.ts +5 -31
- package/dist/backends/evermemos.defaults.json +1 -3
- package/dist/backends/evermemos.js +5 -75
- package/dist/config.d.ts +13 -1
- package/dist/config.js +40 -6
- package/dist/index.js +149 -38
- package/docker/evermemos/Dockerfile +0 -7
- package/package.json +3 -3
- package/docker/evermemos/trace_overlay.py +0 -128
package/README.md
CHANGED
|
@@ -1,65 +1,60 @@
|
|
|
1
1
|
# @contextableai/openclaw-memory-rebac
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
│
|
|
16
|
-
│
|
|
17
|
-
│
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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.
|
|
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
|
|
26
|
+
### Why This Design?
|
|
29
27
|
|
|
30
|
-
Most memory systems bundle authorization with storage
|
|
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
|
-
- **
|
|
35
|
-
-
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
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
|
|
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
|
-
- **
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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` |
|
|
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
|
-
"
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
|
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
|
|
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,
|
|
560
|
-
├── backend.ts # MemoryBackend interface
|
|
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 (
|
|
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
|
-
*
|
|
34
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
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>;
|
|
@@ -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
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
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
|
|
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
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
]
|
|
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
|
-
//
|
|
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
|
|
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.
|
|
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,33 @@ 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_promote: Promote recent memories into the long-term knowledge graph.\n" +
|
|
690
|
+
"</memory-tools>";
|
|
691
|
+
if (liminalResults.length === 0)
|
|
692
|
+
return { prependContext: toolHint };
|
|
693
|
+
const memoryContext = liminalResults
|
|
694
|
+
.map((r) => `[${r.type}:${r.uuid}] ${r.summary}${r.context ? ` (${r.context})` : ""}`)
|
|
695
|
+
.join("\n");
|
|
696
|
+
api.logger.info?.(`openclaw-memory-rebac: injecting ${liminalResults.length} liminal memories`);
|
|
697
|
+
return {
|
|
698
|
+
prependContext: `${toolHint}\n\n<recent-context>\nRecent conversational memory:\n${memoryContext}\n</recent-context>`,
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
// Unified mode: query primary backend via SpiceDB-authorized groups (unchanged)
|
|
638
702
|
const authorizedGroups = await lookupAuthorizedGroups(spicedb, subject, state.lastWriteToken);
|
|
639
703
|
const longTermGroups = authorizedGroups.filter((g) => !isSessionGroup(g));
|
|
640
704
|
const sessionGroups = authorizedGroups.filter(isSessionGroup);
|
|
@@ -658,8 +722,8 @@ const rebacMemoryPlugin = {
|
|
|
658
722
|
const sessionResults = allResults.filter((r) => sessionGroupSet.has(r.group_id));
|
|
659
723
|
const totalCount = allResults.length;
|
|
660
724
|
const toolHint = "<memory-tools>\n" +
|
|
661
|
-
"You have
|
|
662
|
-
"- memory_recall:
|
|
725
|
+
"You have memory tools:\n" +
|
|
726
|
+
"- memory_recall: Use this BEFORE saying you don't know or remember something. Search for facts, preferences, people, decisions.\n" +
|
|
663
727
|
"- memory_store: Save important new information (preferences, decisions, facts about people).\n" +
|
|
664
728
|
"</memory-tools>";
|
|
665
729
|
if (totalCount === 0)
|
|
@@ -727,6 +791,53 @@ const rebacMemoryPlugin = {
|
|
|
727
791
|
if (conversationLines.length === 0)
|
|
728
792
|
return;
|
|
729
793
|
const episodeBody = conversationLines.join("\n");
|
|
794
|
+
if (isHybrid) {
|
|
795
|
+
// Hybrid mode: store to liminal backend only (fire-and-forget, no SpiceDB).
|
|
796
|
+
// Primary backend (Graphiti) gets data only via explicit memory_store or memory_promote.
|
|
797
|
+
const liminalGroupId = liminalDefaultGroupId;
|
|
798
|
+
await liminal.store({
|
|
799
|
+
content: episodeBody,
|
|
800
|
+
groupId: liminalGroupId,
|
|
801
|
+
sourceDescription: "auto-captured conversation",
|
|
802
|
+
});
|
|
803
|
+
// Liminal session enrichment
|
|
804
|
+
if (liminal.enrichSession && state.sessionId) {
|
|
805
|
+
const lastUserMsg = [...event.messages]
|
|
806
|
+
.reverse()
|
|
807
|
+
.find((m) => m.role === "user");
|
|
808
|
+
const lastAssistMsg = [...event.messages]
|
|
809
|
+
.reverse()
|
|
810
|
+
.find((m) => m.role === "assistant");
|
|
811
|
+
const extractText = (m) => {
|
|
812
|
+
if (!m || typeof m !== "object")
|
|
813
|
+
return "";
|
|
814
|
+
const obj = m;
|
|
815
|
+
if (typeof obj.content === "string")
|
|
816
|
+
return obj.content;
|
|
817
|
+
if (Array.isArray(obj.content)) {
|
|
818
|
+
return obj.content
|
|
819
|
+
.filter((b) => typeof b === "object" && b !== null &&
|
|
820
|
+
b.type === "text")
|
|
821
|
+
.map((b) => b.text)
|
|
822
|
+
.join("\n");
|
|
823
|
+
}
|
|
824
|
+
return "";
|
|
825
|
+
};
|
|
826
|
+
const userMsg = stripEnvelopeMetadata(extractText(lastUserMsg));
|
|
827
|
+
const assistantMsg = extractText(lastAssistMsg);
|
|
828
|
+
if (userMsg && assistantMsg) {
|
|
829
|
+
liminal.enrichSession({
|
|
830
|
+
sessionId: state.sessionId,
|
|
831
|
+
groupId: liminalGroupId,
|
|
832
|
+
userMsg,
|
|
833
|
+
assistantMsg,
|
|
834
|
+
}).catch(() => { });
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
api.logger.info(`openclaw-memory-rebac: auto-captured ${conversationLines.length} messages to liminal (${liminalGroupId})`);
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
// Unified mode: store to primary with full SpiceDB write chain
|
|
730
841
|
const targetGroupId = state.sessionId
|
|
731
842
|
? sessionGroupId(state.sessionId)
|
|
732
843
|
: backendDefaultGroupId;
|
|
@@ -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
|
-
"description": "OpenClaw
|
|
3
|
+
"version": "0.5.0",
|
|
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": {
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"./dist/index.js"
|
|
67
67
|
],
|
|
68
68
|
"install": {
|
|
69
|
-
"npmSpec": "@contextableai/openclaw-memory-rebac@
|
|
69
|
+
"npmSpec": "@contextableai/openclaw-memory-rebac@latest",
|
|
70
70
|
"localPath": "extensions/openclaw-memory-rebac",
|
|
71
71
|
"defaultChoice": "npm"
|
|
72
72
|
}
|
|
@@ -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)
|