@vainplex/openclaw-membrane 0.3.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/CHANGELOG.md +34 -0
- package/README.md +257 -0
- package/assets/proto/membrane/v1/membrane.proto +192 -0
- package/dist/buffer.js +106 -0
- package/dist/client.js +52 -0
- package/dist/index.js +181 -0
- package/dist/mapping.js +123 -0
- package/dist/parser.js +99 -0
- package/dist/types.js +14 -0
- package/openclaw.plugin.json +50 -0
- package/package.json +53 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.3.0] — 2026-02-23
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `membrane_search` tool — gRPC Retrieve with client-side filtering, prioritizes user/assistant messages
|
|
7
|
+
- `before_agent_start` hook — auto-injects `<membrane-context>` into system prompt
|
|
8
|
+
- `parser.ts` — shared record parser for all 4 Membrane memory types (episodic, semantic, competence, working)
|
|
9
|
+
- `types.ts` — full TypeScript interfaces (PluginApi, PluginConfig, MembraneRecord, etc.)
|
|
10
|
+
- `validateConfig()` — typed config extraction (rejects wrong types)
|
|
11
|
+
- `maxRetries` cap (10) on reliability buffer — prevents infinite retry loops
|
|
12
|
+
- 44 tests across 4 files (parser, mapping, buffer, config)
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- `register()` refactored from 277 lines to 38 (split into 7 focused functions)
|
|
16
|
+
- `mapEvent()` refactored from 82 lines to 10 (6 individual mappers)
|
|
17
|
+
- Zero `any` remaining (was 18)
|
|
18
|
+
- Logger passed to ReliabilityManager (was `console.error`)
|
|
19
|
+
- All catches log with `logger.debug/warn` (was silent `catch {}`)
|
|
20
|
+
|
|
21
|
+
### Removed
|
|
22
|
+
- Hardcoded API key fallback — auth via `MEMBRANE_API_KEY` env only
|
|
23
|
+
- Module-level mutable state — now closure-scoped in `register()`
|
|
24
|
+
|
|
25
|
+
### Security
|
|
26
|
+
- Removed `'anothersecretapi456'` hardcoded credential from client.ts
|
|
27
|
+
|
|
28
|
+
## [0.1.0] — 2026-02-23
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
- Initial plugin: event ingestion via gRPC IngestEvent
|
|
32
|
+
- Ring buffer with retry logic and exponential backoff
|
|
33
|
+
- Event mapping for 6 OpenClaw event types
|
|
34
|
+
- Sensitivity mapping with secure fallback
|
package/README.md
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# @vainplex/openclaw-membrane
|
|
2
|
+
|
|
3
|
+
Membrane gRPC bridge for OpenClaw — persistent episodic memory via [GustyCube/membrane](https://github.com/GustyCube/membrane) sidecar.
|
|
4
|
+
|
|
5
|
+
**What it does:** Every conversation, tool call, and decision flows into Membrane's biological memory model. Memories decay over time. Frequently accessed ones grow stronger. Your agent remembers what matters and forgets what doesn't.
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
### 1. Run Membrane sidecar
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# Docker (recommended)
|
|
13
|
+
docker run -d --name membrane \
|
|
14
|
+
-p 50051:50051 \
|
|
15
|
+
-v membrane-data:/data \
|
|
16
|
+
openclaw-membrane:local \
|
|
17
|
+
membraned -config /app/config.yaml
|
|
18
|
+
|
|
19
|
+
# Or use docker-compose (see below)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
<details>
|
|
23
|
+
<summary>docker-compose.yml</summary>
|
|
24
|
+
|
|
25
|
+
```yaml
|
|
26
|
+
services:
|
|
27
|
+
membrane:
|
|
28
|
+
image: openclaw-membrane:local
|
|
29
|
+
container_name: membrane
|
|
30
|
+
ports:
|
|
31
|
+
- "50051:50051"
|
|
32
|
+
volumes:
|
|
33
|
+
- ./data:/data:rw
|
|
34
|
+
- ./config.yaml:/app/config.yaml:ro
|
|
35
|
+
entrypoint: ["membraned", "-config", "/app/config.yaml"]
|
|
36
|
+
restart: unless-stopped
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Minimal `config.yaml`:
|
|
40
|
+
```yaml
|
|
41
|
+
listen_addr: ":50051"
|
|
42
|
+
db_path: "/data/membrane.db"
|
|
43
|
+
storage_backend: "sqlite"
|
|
44
|
+
log_level: "info"
|
|
45
|
+
```
|
|
46
|
+
</details>
|
|
47
|
+
|
|
48
|
+
### 2. Install the plugin
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# From npm
|
|
52
|
+
cd ~/.openclaw/extensions
|
|
53
|
+
mkdir openclaw-membrane && cd openclaw-membrane
|
|
54
|
+
npm install @vainplex/openclaw-membrane
|
|
55
|
+
|
|
56
|
+
# Or from source
|
|
57
|
+
git clone https://github.com/alberthild/openclaw-membrane.git
|
|
58
|
+
cd openclaw-membrane
|
|
59
|
+
npm install && npx tsc
|
|
60
|
+
cp -r dist/ ~/.openclaw/extensions/openclaw-membrane/dist/
|
|
61
|
+
cp openclaw.plugin.json package.json ~/.openclaw/extensions/openclaw-membrane/
|
|
62
|
+
cd ~/.openclaw/extensions/openclaw-membrane && npm install --production
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### 3. Enable in OpenClaw
|
|
66
|
+
|
|
67
|
+
Add to your `openclaw.json`:
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"plugins": {
|
|
71
|
+
"entries": {
|
|
72
|
+
"openclaw-membrane": {
|
|
73
|
+
"enabled": true,
|
|
74
|
+
"config": {
|
|
75
|
+
"grpc_endpoint": "localhost:50051"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### 4. Restart gateway
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
openclaw doctor --fix
|
|
87
|
+
openclaw gateway restart
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### 5. Verify
|
|
91
|
+
|
|
92
|
+
Check gateway logs for:
|
|
93
|
+
```
|
|
94
|
+
[membrane] Registered bridge to localhost:50051
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Send a message — it should appear as a Membrane record. Use the `membrane_search` tool to query:
|
|
98
|
+
```
|
|
99
|
+
membrane_search("what did we discuss yesterday")
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Features
|
|
103
|
+
|
|
104
|
+
| Feature | Description |
|
|
105
|
+
|---------|-------------|
|
|
106
|
+
| **Event Ingestion** | Writes messages, tool calls, facts, and outcomes to Membrane via gRPC |
|
|
107
|
+
| **`membrane_search` Tool** | LLM-callable search — each query boosts salience of matched records (rehearsal) |
|
|
108
|
+
| **Auto-Context** | `before_agent_start` hook injects `<membrane-context>` into the system prompt |
|
|
109
|
+
| **Reliability Buffer** | Ring buffer with exponential backoff, max 10 retries, graceful shutdown flush |
|
|
110
|
+
| **4 Memory Types** | Parses episodic (timeline), semantic (SPO facts), competence (patterns), working (state) |
|
|
111
|
+
|
|
112
|
+
## How it works
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
WRITE PATH
|
|
116
|
+
OpenClaw Events ──→ mapping.ts ──→ buffer.ts ──→ gRPC IngestEvent ──→ Membrane DB
|
|
117
|
+
│
|
|
118
|
+
READ PATH │
|
|
119
|
+
User Prompt ──→ before_agent_start hook ──→ gRPC Retrieve ──→ parser.ts ──┘
|
|
120
|
+
│
|
|
121
|
+
<membrane-context>
|
|
122
|
+
injected into prompt
|
|
123
|
+
|
|
124
|
+
SEARCH PATH
|
|
125
|
+
LLM calls membrane_search ──→ gRPC Retrieve ──→ parser.ts ──→ formatted results
|
|
126
|
+
│
|
|
127
|
+
salience boosted
|
|
128
|
+
(rehearsal effect)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**Write path:** Every OpenClaw event (message in/out, tool call, fact extraction, task outcome) is mapped to a Membrane gRPC call and queued in a ring buffer. Failed calls retry with exponential backoff.
|
|
132
|
+
|
|
133
|
+
**Read path:** Before each agent turn, the plugin calls Membrane's Retrieve with the user prompt. Matching records are parsed, filtered (prioritizing user/assistant messages over tool calls), and injected as `<membrane-context>`.
|
|
134
|
+
|
|
135
|
+
**Search path:** The `membrane_search` tool lets the LLM explicitly query Membrane. Each Retrieve call triggers Membrane's rehearsal mechanism — accessed memories gain salience and resist decay.
|
|
136
|
+
|
|
137
|
+
## Event Mapping
|
|
138
|
+
|
|
139
|
+
| OpenClaw Event | Membrane Method | Memory Kind |
|
|
140
|
+
|---------------|----------------|-------------|
|
|
141
|
+
| `message_received` | IngestEvent | user_message |
|
|
142
|
+
| `message_sent` | IngestEvent | assistant_message |
|
|
143
|
+
| `session_start` | IngestEvent | session_init |
|
|
144
|
+
| `after_tool_call` | IngestToolOutput | tool_call |
|
|
145
|
+
| `fact_extracted` | IngestObservation | semantic |
|
|
146
|
+
| `task_completed` | IngestOutcome | outcome |
|
|
147
|
+
|
|
148
|
+
## Configuration
|
|
149
|
+
|
|
150
|
+
### External Config (recommended)
|
|
151
|
+
|
|
152
|
+
Create `~/.openclaw/plugins/openclaw-membrane/config.json`:
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
{
|
|
156
|
+
"grpc_endpoint": "localhost:50051",
|
|
157
|
+
"buffer_size": 1000,
|
|
158
|
+
"default_sensitivity": "low",
|
|
159
|
+
"retrieve_enabled": true,
|
|
160
|
+
"retrieve_limit": 5,
|
|
161
|
+
"retrieve_min_salience": 0.1,
|
|
162
|
+
"retrieve_max_sensitivity": "medium",
|
|
163
|
+
"retrieve_timeout_ms": 10000
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Config Reference
|
|
168
|
+
|
|
169
|
+
| Property | Type | Default | Description |
|
|
170
|
+
|----------|------|---------|-------------|
|
|
171
|
+
| `grpc_endpoint` | string | `localhost:50051` | Membrane gRPC address |
|
|
172
|
+
| `buffer_size` | number | `1000` | Ring buffer capacity |
|
|
173
|
+
| `default_sensitivity` | string | `low` | Default event sensitivity (`public`/`low`/`medium`/`high`/`hyper`) |
|
|
174
|
+
| `retrieve_enabled` | boolean | `true` | Enable auto-context injection |
|
|
175
|
+
| `retrieve_limit` | number | `5` | Max memories per turn |
|
|
176
|
+
| `retrieve_min_salience` | number | `0.1` | Min salience for Retrieve |
|
|
177
|
+
| `retrieve_max_sensitivity` | string | `medium` | Max sensitivity for Retrieve |
|
|
178
|
+
| `retrieve_timeout_ms` | number | `2000` | Retrieve timeout in ms |
|
|
179
|
+
|
|
180
|
+
### Sensitivity Model
|
|
181
|
+
|
|
182
|
+
Events are classified by sensitivity based on context:
|
|
183
|
+
|
|
184
|
+
| Condition | Sensitivity |
|
|
185
|
+
|-----------|------------|
|
|
186
|
+
| Credential/auth events | `hyper` |
|
|
187
|
+
| DM / private channel | `medium` |
|
|
188
|
+
| Tool calls | `medium` |
|
|
189
|
+
| Default | config value (`low`) |
|
|
190
|
+
| Invalid/unknown | `hyper` (secure fallback) |
|
|
191
|
+
|
|
192
|
+
### Authentication
|
|
193
|
+
|
|
194
|
+
Set `MEMBRANE_API_KEY` environment variable if your Membrane instance requires auth.
|
|
195
|
+
|
|
196
|
+
## Architecture
|
|
197
|
+
|
|
198
|
+
```
|
|
199
|
+
openclaw-membrane/
|
|
200
|
+
├── index.ts # Plugin entry: wiring, tool + hook registration (248 LOC)
|
|
201
|
+
├── types.ts # TypeScript interfaces, config defaults (132 LOC)
|
|
202
|
+
├── parser.ts # Shared record parser, all 4 memory types (136 LOC)
|
|
203
|
+
├── mapping.ts # Event → gRPC payload mapping, 6 types (151 LOC)
|
|
204
|
+
├── buffer.ts # Ring buffer + reliability manager (120 LOC)
|
|
205
|
+
├── client.ts # gRPC client wrapper (71 LOC)
|
|
206
|
+
├── test/
|
|
207
|
+
│ ├── parser.test.ts # 15 tests
|
|
208
|
+
│ ├── mapping.test.ts # 15 tests
|
|
209
|
+
│ ├── buffer.test.ts # 6 tests
|
|
210
|
+
│ └── config.test.ts # 8 tests
|
|
211
|
+
└── assets/proto/ # Membrane gRPC proto definitions
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
**Total:** 858 LOC source, 44 tests, 0 `any`.
|
|
215
|
+
|
|
216
|
+
## Tests
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
npx vitest run # Run all 44 tests
|
|
220
|
+
npx vitest --watch # Watch mode
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Known Limitations
|
|
224
|
+
|
|
225
|
+
**Be honest about what this can and can't do today:**
|
|
226
|
+
|
|
227
|
+
- **No fulltext search.** Membrane's Retrieve ranks by salience/recency/decay, not text content. If you search for "pipeline", it won't find records containing "pipeline" — it returns whatever has highest salience. This gets better over time as rehearsal boosts relevant records, but early on results can feel random.
|
|
228
|
+
- **Backfilled records don't benefit from decay.** If you bulk-import history, all records start with identical salience and no access history. Membrane's biological memory model needs organic growth — records written through real conversations develop natural salience patterns.
|
|
229
|
+
- **Single memory slot in OpenClaw.** The `before_agent_start` hook only fires for the active memory plugin. If you use `memory-lancedb` (default), Membrane's auto-context injection won't fire. The `membrane_search` tool works regardless.
|
|
230
|
+
- **No vector/semantic search.** Membrane doesn't have embedding-based search yet. The `membrane_search` tool queries via gRPC Retrieve which uses salience-based ranking. For fulltext needs, use the bundled `scripts/membrane-search.sh` (SQL LIKE queries directly on the SQLite DB).
|
|
231
|
+
- **Consolidation is early.** Membrane runs consolidation every 6h with 4 sub-consolidators, but the quality of merged records depends on your data volume and patterns.
|
|
232
|
+
- **SQLite scaling.** Works well up to ~100k records. Beyond that, query performance may degrade. No sharding or distributed mode.
|
|
233
|
+
|
|
234
|
+
## Membrane — Credit & Collaboration
|
|
235
|
+
|
|
236
|
+
This plugin bridges [GustyCube/membrane](https://github.com/GustyCube/membrane) — a Go-based gRPC service (7.2k LOC) implementing biological memory dynamics: episodic timeline, semantic facts, competence learning, working memory, salience decay, and revision operations (supersede/fork/retract/contest/merge). The [RFC specification](https://github.com/GustyCube/membrane/blob/main/RFC.md) (849 lines) is one of the best-documented agent memory specs out there.
|
|
237
|
+
|
|
238
|
+
This plugin is the **bridge layer** — it handles OpenClaw event mapping, buffered ingestion, search tooling, and context injection. Membrane does the heavy lifting on storage, decay math, and memory consolidation.
|
|
239
|
+
|
|
240
|
+
- **Membrane docs:** [membrane.gustycube.com](https://membrane.gustycube.com)
|
|
241
|
+
- **TS Client:** `@gustycube/membrane` on npm
|
|
242
|
+
- **Memory types:** episodic (timeline), semantic (SPO triples), competence (learned patterns), working (task state)
|
|
243
|
+
- **Revision ops:** supersede, fork, retract, contest, merge
|
|
244
|
+
|
|
245
|
+
## Vainplex OpenClaw Plugin Suite
|
|
246
|
+
|
|
247
|
+
| # | Plugin | Version | Tests | Description |
|
|
248
|
+
|---|--------|---------|-------|-------------|
|
|
249
|
+
| 1 | [@vainplex/openclaw-nats-eventstore](https://github.com/alberthild/openclaw-nats-eventstore) | 0.2.1 | 60 | NATS JetStream event persistence |
|
|
250
|
+
| 2 | [@vainplex/openclaw-cortex](https://github.com/alberthild/openclaw-cortex) | 0.4.2 | 756 | Boot context, threads, decisions, trace analysis |
|
|
251
|
+
| 3 | [@vainplex/openclaw-governance](https://github.com/alberthild/openclaw-governance) | 0.3.2 | 402 | Policy engine, trust scores, credential guard |
|
|
252
|
+
| 4 | [@vainplex/openclaw-knowledge-engine](https://github.com/alberthild/openclaw-knowledge-engine) | 0.1.4 | 94 | LanceDB knowledge extraction + search |
|
|
253
|
+
| 5 | **@vainplex/openclaw-membrane** | **0.3.0** | **44** | **Membrane episodic memory bridge** |
|
|
254
|
+
|
|
255
|
+
## License
|
|
256
|
+
|
|
257
|
+
MIT
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
syntax = "proto3";
|
|
2
|
+
|
|
3
|
+
package membrane.v1;
|
|
4
|
+
|
|
5
|
+
option go_package = "github.com/GustyCube/membrane/api/grpc/gen/membranev1";
|
|
6
|
+
|
|
7
|
+
service MembraneService {
|
|
8
|
+
rpc IngestEvent(IngestEventRequest) returns (IngestResponse);
|
|
9
|
+
rpc IngestToolOutput(IngestToolOutputRequest) returns (IngestResponse);
|
|
10
|
+
rpc IngestObservation(IngestObservationRequest) returns (IngestResponse);
|
|
11
|
+
rpc IngestOutcome(IngestOutcomeRequest) returns (IngestResponse);
|
|
12
|
+
rpc IngestWorkingState(IngestWorkingStateRequest) returns (IngestResponse);
|
|
13
|
+
rpc Retrieve(RetrieveRequest) returns (RetrieveResponse);
|
|
14
|
+
rpc RetrieveByID(RetrieveByIDRequest) returns (MemoryRecordResponse);
|
|
15
|
+
rpc Supersede(SupersedeRequest) returns (MemoryRecordResponse);
|
|
16
|
+
rpc Fork(ForkRequest) returns (MemoryRecordResponse);
|
|
17
|
+
rpc Retract(RetractRequest) returns (RetractResponse);
|
|
18
|
+
rpc Merge(MergeRequest) returns (MemoryRecordResponse);
|
|
19
|
+
rpc Reinforce(ReinforceRequest) returns (ReinforceResponse);
|
|
20
|
+
rpc Penalize(PenalizeRequest) returns (PenalizeResponse);
|
|
21
|
+
rpc GetMetrics(GetMetricsRequest) returns (MetricsResponse);
|
|
22
|
+
rpc Contest(ContestRequest) returns (ContestResponse);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Ingestion messages
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
message IngestEventRequest {
|
|
30
|
+
string source = 1;
|
|
31
|
+
string event_kind = 2;
|
|
32
|
+
string ref = 3;
|
|
33
|
+
string summary = 4;
|
|
34
|
+
string timestamp = 5; // RFC 3339
|
|
35
|
+
repeated string tags = 6;
|
|
36
|
+
string scope = 7;
|
|
37
|
+
string sensitivity = 8;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
message IngestToolOutputRequest {
|
|
41
|
+
string source = 1;
|
|
42
|
+
string tool_name = 2;
|
|
43
|
+
bytes args = 3; // JSON-encoded map
|
|
44
|
+
bytes result = 4; // JSON-encoded value
|
|
45
|
+
repeated string depends_on = 5;
|
|
46
|
+
string timestamp = 6; // RFC 3339
|
|
47
|
+
repeated string tags = 7;
|
|
48
|
+
string scope = 8;
|
|
49
|
+
string sensitivity = 9;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
message IngestObservationRequest {
|
|
53
|
+
string source = 1;
|
|
54
|
+
string subject = 2;
|
|
55
|
+
string predicate = 3;
|
|
56
|
+
bytes object = 4; // JSON-encoded value
|
|
57
|
+
string timestamp = 5; // RFC 3339
|
|
58
|
+
repeated string tags = 6;
|
|
59
|
+
string scope = 7;
|
|
60
|
+
string sensitivity = 8;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
message IngestOutcomeRequest {
|
|
64
|
+
string source = 1;
|
|
65
|
+
string target_record_id = 2;
|
|
66
|
+
string outcome_status = 3; // success | failure | partial
|
|
67
|
+
string timestamp = 4; // RFC 3339
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
message IngestWorkingStateRequest {
|
|
71
|
+
string source = 1;
|
|
72
|
+
string thread_id = 2;
|
|
73
|
+
string state = 3;
|
|
74
|
+
repeated string next_actions = 4;
|
|
75
|
+
repeated string open_questions = 5;
|
|
76
|
+
string context_summary = 6;
|
|
77
|
+
bytes active_constraints = 7; // JSON-encoded []Constraint
|
|
78
|
+
string timestamp = 8; // RFC 3339
|
|
79
|
+
repeated string tags = 9;
|
|
80
|
+
string scope = 10;
|
|
81
|
+
string sensitivity = 11;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
message IngestResponse {
|
|
85
|
+
bytes record = 1; // JSON-encoded MemoryRecord
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Retrieval messages
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
message TrustContext {
|
|
93
|
+
string max_sensitivity = 1;
|
|
94
|
+
bool authenticated = 2;
|
|
95
|
+
string actor_id = 3;
|
|
96
|
+
repeated string scopes = 4;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
message RetrieveRequest {
|
|
100
|
+
string task_descriptor = 1;
|
|
101
|
+
TrustContext trust = 2;
|
|
102
|
+
repeated string memory_types = 3;
|
|
103
|
+
double min_salience = 4;
|
|
104
|
+
int32 limit = 5;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
message RetrieveResponse {
|
|
108
|
+
repeated bytes records = 1; // JSON-encoded MemoryRecord array
|
|
109
|
+
bytes selection = 2; // JSON-encoded SelectionResult, optional
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
message RetrieveByIDRequest {
|
|
113
|
+
string id = 1;
|
|
114
|
+
TrustContext trust = 2;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
message MemoryRecordResponse {
|
|
118
|
+
bytes record = 1; // JSON-encoded MemoryRecord
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Revision messages
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
message SupersedeRequest {
|
|
126
|
+
string old_id = 1;
|
|
127
|
+
bytes new_record = 2; // JSON-encoded MemoryRecord
|
|
128
|
+
string actor = 3;
|
|
129
|
+
string rationale = 4;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
message ForkRequest {
|
|
133
|
+
string source_id = 1;
|
|
134
|
+
bytes forked_record = 2; // JSON-encoded MemoryRecord
|
|
135
|
+
string actor = 3;
|
|
136
|
+
string rationale = 4;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
message RetractRequest {
|
|
140
|
+
string id = 1;
|
|
141
|
+
string actor = 2;
|
|
142
|
+
string rationale = 3;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
message RetractResponse {}
|
|
146
|
+
|
|
147
|
+
message MergeRequest {
|
|
148
|
+
repeated string ids = 1;
|
|
149
|
+
bytes merged_record = 2; // JSON-encoded MemoryRecord
|
|
150
|
+
string actor = 3;
|
|
151
|
+
string rationale = 4;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
message ContestRequest {
|
|
155
|
+
string id = 1;
|
|
156
|
+
string contesting_ref = 2;
|
|
157
|
+
string actor = 3;
|
|
158
|
+
string rationale = 4;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
message ContestResponse {}
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Decay messages
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
message ReinforceRequest {
|
|
168
|
+
string id = 1;
|
|
169
|
+
string actor = 2;
|
|
170
|
+
string rationale = 3;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
message ReinforceResponse {}
|
|
174
|
+
|
|
175
|
+
message PenalizeRequest {
|
|
176
|
+
string id = 1;
|
|
177
|
+
double amount = 2;
|
|
178
|
+
string actor = 3;
|
|
179
|
+
string rationale = 4;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
message PenalizeResponse {}
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// Metrics messages
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
message GetMetricsRequest {}
|
|
189
|
+
|
|
190
|
+
message MetricsResponse {
|
|
191
|
+
bytes snapshot = 1; // JSON-encoded metrics.Snapshot
|
|
192
|
+
}
|
package/dist/buffer.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reliability buffer for Membrane gRPC calls.
|
|
3
|
+
* Ring buffer with retry logic and exponential backoff.
|
|
4
|
+
*/
|
|
5
|
+
export class RingBuffer {
|
|
6
|
+
buffer;
|
|
7
|
+
head = 0;
|
|
8
|
+
tail = 0;
|
|
9
|
+
size;
|
|
10
|
+
count = 0;
|
|
11
|
+
constructor(size) {
|
|
12
|
+
this.size = size;
|
|
13
|
+
this.buffer = new Array(size).fill(null);
|
|
14
|
+
}
|
|
15
|
+
push(item) {
|
|
16
|
+
if (this.count === this.size) {
|
|
17
|
+
this.tail = (this.tail + 1) % this.size;
|
|
18
|
+
this.count--;
|
|
19
|
+
}
|
|
20
|
+
this.buffer[this.head] = item;
|
|
21
|
+
this.head = (this.head + 1) % this.size;
|
|
22
|
+
this.count++;
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
pop() {
|
|
26
|
+
if (this.count === 0)
|
|
27
|
+
return null;
|
|
28
|
+
const item = this.buffer[this.tail];
|
|
29
|
+
this.buffer[this.tail] = null;
|
|
30
|
+
this.tail = (this.tail + 1) % this.size;
|
|
31
|
+
this.count--;
|
|
32
|
+
return item;
|
|
33
|
+
}
|
|
34
|
+
get length() {
|
|
35
|
+
return this.count;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export class ReliabilityManager {
|
|
39
|
+
processor;
|
|
40
|
+
logger;
|
|
41
|
+
buffer;
|
|
42
|
+
processing = false;
|
|
43
|
+
retryDelay = 100;
|
|
44
|
+
maxRetryDelay = 30000;
|
|
45
|
+
maxRetries = 10;
|
|
46
|
+
constructor(size, processor, logger) {
|
|
47
|
+
this.processor = processor;
|
|
48
|
+
this.logger = logger;
|
|
49
|
+
const safeSize = Math.max(size || 0, 1000);
|
|
50
|
+
this.buffer = new RingBuffer(safeSize);
|
|
51
|
+
}
|
|
52
|
+
enqueue(method, payload) {
|
|
53
|
+
this.buffer.push({
|
|
54
|
+
method,
|
|
55
|
+
payload,
|
|
56
|
+
retries: 0,
|
|
57
|
+
timestamp: Date.now()
|
|
58
|
+
});
|
|
59
|
+
this.process();
|
|
60
|
+
}
|
|
61
|
+
async process() {
|
|
62
|
+
if (this.processing || this.buffer.length === 0)
|
|
63
|
+
return;
|
|
64
|
+
this.processing = true;
|
|
65
|
+
while (this.buffer.length > 0) {
|
|
66
|
+
const item = this.buffer.pop();
|
|
67
|
+
if (!item)
|
|
68
|
+
break;
|
|
69
|
+
try {
|
|
70
|
+
await this.processor(item);
|
|
71
|
+
this.retryDelay = 100;
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
this.logger?.warn(`[membrane] Error processing ${item.method}: ${err instanceof Error ? err.message : String(err)}`);
|
|
75
|
+
item.retries++;
|
|
76
|
+
if (item.retries > this.maxRetries) {
|
|
77
|
+
this.logger?.warn(`[membrane] Dropping ${item.method} after ${this.maxRetries} retries`);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const delay = Math.min(this.retryDelay * Math.pow(2, item.retries), this.maxRetryDelay);
|
|
81
|
+
this.buffer.push(item);
|
|
82
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
this.processing = false;
|
|
86
|
+
}
|
|
87
|
+
async flush(timeoutMs) {
|
|
88
|
+
const start = Date.now();
|
|
89
|
+
let dropped = 0;
|
|
90
|
+
while (this.buffer.length > 0 && (Date.now() - start) < timeoutMs) {
|
|
91
|
+
const item = this.buffer.pop();
|
|
92
|
+
if (item) {
|
|
93
|
+
try {
|
|
94
|
+
await this.processor(item);
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
dropped++;
|
|
98
|
+
this.logger?.warn(`[membrane] Flush failed for ${item.method}, dropped (${dropped} total)`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (this.buffer.length > 0) {
|
|
103
|
+
this.logger?.warn(`[membrane] Flush timeout: ${this.buffer.length} items still in buffer`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Membrane gRPC client wrapper.
|
|
3
|
+
* Connects to the Membrane sidecar and provides typed method calls.
|
|
4
|
+
*/
|
|
5
|
+
import * as grpc from '@grpc/grpc-js';
|
|
6
|
+
import * as protoLoader from '@grpc/proto-loader';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = path.dirname(__filename);
|
|
11
|
+
export class MembraneClient {
|
|
12
|
+
endpoint;
|
|
13
|
+
client;
|
|
14
|
+
constructor(endpoint, customProtoPath) {
|
|
15
|
+
this.endpoint = endpoint;
|
|
16
|
+
const protoPath = customProtoPath || path.join(__dirname, 'assets/proto/membrane/v1/membrane.proto');
|
|
17
|
+
const packageDefinition = protoLoader.loadSync(protoPath, {
|
|
18
|
+
keepCase: true,
|
|
19
|
+
longs: String,
|
|
20
|
+
enums: String,
|
|
21
|
+
defaults: true,
|
|
22
|
+
oneofs: true,
|
|
23
|
+
includeDirs: [path.dirname(path.dirname(path.dirname(protoPath)))]
|
|
24
|
+
});
|
|
25
|
+
const membraneProto = grpc.loadPackageDefinition(packageDefinition);
|
|
26
|
+
this.client = new membraneProto.membrane.v1.MembraneService(this.endpoint, grpc.credentials.createInsecure());
|
|
27
|
+
}
|
|
28
|
+
async call(method, payload) {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const metadata = new grpc.Metadata();
|
|
31
|
+
const apiKey = process.env.MEMBRANE_API_KEY;
|
|
32
|
+
if (apiKey) {
|
|
33
|
+
metadata.add('authorization', apiKey);
|
|
34
|
+
}
|
|
35
|
+
if (typeof this.client[method] !== 'function') {
|
|
36
|
+
reject(new Error(`Unknown gRPC method: ${method}`));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
this.client[method](payload, metadata, (err, response) => {
|
|
40
|
+
if (err) {
|
|
41
|
+
reject(err);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
resolve(response);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
close() {
|
|
50
|
+
this.client.close();
|
|
51
|
+
}
|
|
52
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vainplex/openclaw-membrane — Membrane bridge plugin for OpenClaw
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - Event ingestion (write path) via gRPC IngestEvent
|
|
6
|
+
* - `membrane_search` tool for episodic memory queries
|
|
7
|
+
* - `before_agent_start` hook for auto-context injection
|
|
8
|
+
*/
|
|
9
|
+
import { MembraneClient } from './client.js';
|
|
10
|
+
import { ReliabilityManager } from './buffer.js';
|
|
11
|
+
import { mapEvent, mapSensitivity } from './mapping.js';
|
|
12
|
+
import { parseMembraneRecords, selectMemories } from './parser.js';
|
|
13
|
+
import { DEFAULT_CONFIG } from './types.js';
|
|
14
|
+
// --- Config (exported for testing) ---
|
|
15
|
+
export function createConfig(rawConfig) {
|
|
16
|
+
return {
|
|
17
|
+
...DEFAULT_CONFIG,
|
|
18
|
+
...validateConfig(rawConfig),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function validateConfig(raw) {
|
|
22
|
+
const result = {};
|
|
23
|
+
if (typeof raw.grpc_endpoint === 'string')
|
|
24
|
+
result.grpc_endpoint = raw.grpc_endpoint;
|
|
25
|
+
if (typeof raw.buffer_size === 'number')
|
|
26
|
+
result.buffer_size = raw.buffer_size;
|
|
27
|
+
if (typeof raw.default_sensitivity === 'string')
|
|
28
|
+
result.default_sensitivity = raw.default_sensitivity;
|
|
29
|
+
if (typeof raw.retrieve_enabled === 'boolean')
|
|
30
|
+
result.retrieve_enabled = raw.retrieve_enabled;
|
|
31
|
+
if (typeof raw.retrieve_limit === 'number')
|
|
32
|
+
result.retrieve_limit = raw.retrieve_limit;
|
|
33
|
+
if (typeof raw.retrieve_min_salience === 'number')
|
|
34
|
+
result.retrieve_min_salience = raw.retrieve_min_salience;
|
|
35
|
+
if (typeof raw.retrieve_max_sensitivity === 'string')
|
|
36
|
+
result.retrieve_max_sensitivity = raw.retrieve_max_sensitivity;
|
|
37
|
+
if (typeof raw.retrieve_timeout_ms === 'number')
|
|
38
|
+
result.retrieve_timeout_ms = raw.retrieve_timeout_ms;
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
// --- Retrieve helper ---
|
|
42
|
+
async function retrieveMemories(client, query, config, fetchLimit) {
|
|
43
|
+
const request = {
|
|
44
|
+
task_descriptor: query.substring(0, 500),
|
|
45
|
+
trust: {
|
|
46
|
+
max_sensitivity: config.retrieve_max_sensitivity,
|
|
47
|
+
authenticated: true,
|
|
48
|
+
actor_id: 'openclaw-main',
|
|
49
|
+
scopes: [],
|
|
50
|
+
},
|
|
51
|
+
memory_types: [],
|
|
52
|
+
min_salience: config.retrieve_min_salience,
|
|
53
|
+
limit: fetchLimit,
|
|
54
|
+
};
|
|
55
|
+
const result = await Promise.race([
|
|
56
|
+
client.call('Retrieve', request),
|
|
57
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), config.retrieve_timeout_ms)),
|
|
58
|
+
]);
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
// --- Tool handler ---
|
|
62
|
+
async function handleSearch(client, config, logger, params) {
|
|
63
|
+
const query = typeof params.query === 'string' ? params.query : '';
|
|
64
|
+
const limit = Math.min(typeof params.limit === 'number' ? params.limit : 5, 20);
|
|
65
|
+
if (!query || query.length < 3) {
|
|
66
|
+
return { content: [{ type: 'text', text: 'Query too short. Please provide a more specific search.' }] };
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const result = await retrieveMemories(client, query, config, Math.max(limit * 10, 50));
|
|
70
|
+
if (!result?.records?.length) {
|
|
71
|
+
return { content: [{ type: 'text', text: 'No relevant memories found in Membrane.' }] };
|
|
72
|
+
}
|
|
73
|
+
const parsed = parseMembraneRecords(result.records, logger);
|
|
74
|
+
const memories = selectMemories(parsed, limit);
|
|
75
|
+
if (memories.length === 0) {
|
|
76
|
+
return { content: [{ type: 'text', text: 'No relevant memories found in Membrane.' }] };
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
content: [{
|
|
80
|
+
type: 'text',
|
|
81
|
+
text: `Found ${memories.length} memories in Membrane (${result.records.length} records scanned):\n\n${memories.join('\n\n')}`,
|
|
82
|
+
}],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
87
|
+
return { content: [{ type: 'text', text: `Membrane search failed: ${msg}` }] };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// --- Context hook handler ---
|
|
91
|
+
async function handleContextInjection(client, config, logger, prompt) {
|
|
92
|
+
if (!prompt || prompt.length < 5)
|
|
93
|
+
return undefined;
|
|
94
|
+
try {
|
|
95
|
+
const fetchLimit = Math.max(config.retrieve_limit * 10, 50);
|
|
96
|
+
const result = await retrieveMemories(client, prompt, config, fetchLimit);
|
|
97
|
+
if (!result?.records?.length)
|
|
98
|
+
return undefined;
|
|
99
|
+
const parsed = parseMembraneRecords(result.records, logger);
|
|
100
|
+
const memories = selectMemories(parsed, config.retrieve_limit);
|
|
101
|
+
if (memories.length === 0)
|
|
102
|
+
return undefined;
|
|
103
|
+
const context = [
|
|
104
|
+
'<membrane-context>',
|
|
105
|
+
'Episodic memory from Membrane (conversation history, tool outputs, observations).',
|
|
106
|
+
'Treat as supplementary context — verify before stating as fact.',
|
|
107
|
+
...memories.map((m, i) => `${i + 1}. ${m}`),
|
|
108
|
+
'</membrane-context>',
|
|
109
|
+
].join('\n');
|
|
110
|
+
logger.info(`[membrane] Injecting ${memories.length} memories (${parsed.conversational.length} conversational + ${parsed.tool.length} tool) into context`);
|
|
111
|
+
return { prependContext: context };
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
115
|
+
logger.debug(`[membrane] Retrieve skipped: ${msg}`);
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// --- Event handler ---
|
|
120
|
+
function handleEvent(event, config, reliability, logger) {
|
|
121
|
+
const normalizedEvent = {
|
|
122
|
+
type: event.type,
|
|
123
|
+
payload: (event.payload || event.data || {}),
|
|
124
|
+
context: event.context,
|
|
125
|
+
};
|
|
126
|
+
const sensitivity = mapSensitivity(normalizedEvent, config.default_sensitivity);
|
|
127
|
+
const mapped = mapEvent(normalizedEvent, sensitivity);
|
|
128
|
+
if (mapped) {
|
|
129
|
+
logger.info(`[membrane] Received event: ${event.type}`);
|
|
130
|
+
reliability.enqueue(mapped.method, mapped.payload);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// --- Shutdown handler ---
|
|
134
|
+
async function handleShutdown(client, reliability, logger) {
|
|
135
|
+
logger.info('[membrane] Shutting down, flushing buffer...');
|
|
136
|
+
await reliability.flush(5000);
|
|
137
|
+
client.close();
|
|
138
|
+
}
|
|
139
|
+
// --- Tool schema ---
|
|
140
|
+
const SEARCH_TOOL_SCHEMA = {
|
|
141
|
+
type: 'object',
|
|
142
|
+
properties: {
|
|
143
|
+
query: { type: 'string', description: 'Search query — what are you looking for? Be specific.' },
|
|
144
|
+
limit: { type: 'number', description: 'Maximum results to return (default: 5, max: 20)' },
|
|
145
|
+
},
|
|
146
|
+
required: ['query'],
|
|
147
|
+
};
|
|
148
|
+
// --- Plugin ---
|
|
149
|
+
const plugin = {
|
|
150
|
+
id: 'openclaw-membrane',
|
|
151
|
+
name: '@vainplex/openclaw-membrane',
|
|
152
|
+
version: '0.3.0',
|
|
153
|
+
register(api) {
|
|
154
|
+
const config = createConfig(api.pluginConfig);
|
|
155
|
+
const logger = api.logger;
|
|
156
|
+
const client = new MembraneClient(config.grpc_endpoint);
|
|
157
|
+
const reliability = new ReliabilityManager(config.buffer_size, async (item) => { await client.call(item.method, item.payload); }, logger);
|
|
158
|
+
logger.info(`[membrane] Registered bridge to ${config.grpc_endpoint}`);
|
|
159
|
+
// Write path: subscribe to events
|
|
160
|
+
api.on('event', (event) => {
|
|
161
|
+
handleEvent(event, config, reliability, logger);
|
|
162
|
+
});
|
|
163
|
+
// Search tool: gRPC Retrieve (boosts salience via rehearsal)
|
|
164
|
+
api.registerTool({
|
|
165
|
+
name: 'membrane_search',
|
|
166
|
+
description: 'Search episodic memory (Membrane) for conversation history, tool outputs, and observations. Use when you need historical context about past conversations, decisions, or events. Returns the most relevant memories matching the query.',
|
|
167
|
+
parameters: SEARCH_TOOL_SCHEMA,
|
|
168
|
+
execute: (_toolCallId, params) => handleSearch(client, config, logger, params),
|
|
169
|
+
});
|
|
170
|
+
// Context hook: auto-inject Membrane memories before agent starts
|
|
171
|
+
if (config.retrieve_enabled) {
|
|
172
|
+
api.on('before_agent_start', (event) => {
|
|
173
|
+
const e = event;
|
|
174
|
+
return handleContextInjection(client, config, logger, e.prompt || e.message || '');
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
// Shutdown: flush buffer, close client
|
|
178
|
+
api.on('stop', () => handleShutdown(client, reliability, logger));
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
export default plugin;
|
package/dist/mapping.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event mapping: OpenClaw events → Membrane gRPC payloads.
|
|
3
|
+
* Each event type has its own mapping function.
|
|
4
|
+
*/
|
|
5
|
+
export const VALID_SENSITIVITIES = ['public', 'low', 'medium', 'high', 'hyper'];
|
|
6
|
+
// --- Sensitivity ---
|
|
7
|
+
export function mapSensitivity(event, defaultConfig) {
|
|
8
|
+
const isCredentialOrAuth = event.type.includes('credential') || event.type.includes('auth');
|
|
9
|
+
let sensitivity;
|
|
10
|
+
if (isCredentialOrAuth) {
|
|
11
|
+
sensitivity = 'hyper';
|
|
12
|
+
}
|
|
13
|
+
else if (event.context?.sensitivity) {
|
|
14
|
+
sensitivity = event.context.sensitivity;
|
|
15
|
+
}
|
|
16
|
+
else if (event.context?.isPrivate || event.context?.channelType === 'dm') {
|
|
17
|
+
sensitivity = 'medium';
|
|
18
|
+
}
|
|
19
|
+
else if (event.type === 'after_tool_call') {
|
|
20
|
+
sensitivity = 'medium';
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
sensitivity = defaultConfig || 'low';
|
|
24
|
+
}
|
|
25
|
+
if (!VALID_SENSITIVITIES.includes(sensitivity)) {
|
|
26
|
+
return 'hyper'; // Secure fallback
|
|
27
|
+
}
|
|
28
|
+
return sensitivity;
|
|
29
|
+
}
|
|
30
|
+
// --- Individual mappers ---
|
|
31
|
+
function mapMessageReceived(payload, timestamp, sensitivity) {
|
|
32
|
+
return {
|
|
33
|
+
method: 'IngestEvent',
|
|
34
|
+
payload: {
|
|
35
|
+
source: 'openclaw',
|
|
36
|
+
event_kind: 'user_message',
|
|
37
|
+
summary: typeof payload.content === 'string' ? payload.content : '',
|
|
38
|
+
timestamp,
|
|
39
|
+
sensitivity,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function mapMessageSent(payload, timestamp, sensitivity) {
|
|
44
|
+
return {
|
|
45
|
+
method: 'IngestEvent',
|
|
46
|
+
payload: {
|
|
47
|
+
source: 'openclaw',
|
|
48
|
+
event_kind: 'assistant_message',
|
|
49
|
+
summary: typeof payload.content === 'string' ? payload.content : '',
|
|
50
|
+
timestamp,
|
|
51
|
+
sensitivity,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function mapSessionStart(timestamp, sensitivity) {
|
|
56
|
+
return {
|
|
57
|
+
method: 'IngestEvent',
|
|
58
|
+
payload: {
|
|
59
|
+
source: 'openclaw',
|
|
60
|
+
event_kind: 'session_init',
|
|
61
|
+
summary: 'New session started',
|
|
62
|
+
timestamp,
|
|
63
|
+
sensitivity,
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function mapToolCall(payload, timestamp, sensitivity) {
|
|
68
|
+
return {
|
|
69
|
+
method: 'IngestToolOutput',
|
|
70
|
+
payload: {
|
|
71
|
+
source: 'openclaw',
|
|
72
|
+
tool_name: payload.toolName,
|
|
73
|
+
args: Buffer.from(JSON.stringify(payload.params || {})),
|
|
74
|
+
result: Buffer.from(JSON.stringify(payload.result || {})),
|
|
75
|
+
timestamp,
|
|
76
|
+
sensitivity,
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function mapFactExtracted(payload, timestamp, sensitivity) {
|
|
81
|
+
return {
|
|
82
|
+
method: 'IngestObservation',
|
|
83
|
+
payload: {
|
|
84
|
+
source: 'openclaw',
|
|
85
|
+
subject: payload.subject,
|
|
86
|
+
predicate: payload.predicate,
|
|
87
|
+
object: Buffer.from(JSON.stringify(payload.object || {})),
|
|
88
|
+
timestamp,
|
|
89
|
+
sensitivity,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function mapTaskCompleted(payload, timestamp) {
|
|
94
|
+
return {
|
|
95
|
+
method: 'IngestOutcome',
|
|
96
|
+
payload: {
|
|
97
|
+
source: 'openclaw',
|
|
98
|
+
target_record_id: payload.targetId,
|
|
99
|
+
outcome_status: payload.success ? 'success' : 'failure',
|
|
100
|
+
timestamp,
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
// --- Main dispatcher ---
|
|
105
|
+
export function mapEvent(event, sensitivity) {
|
|
106
|
+
const timestamp = new Date().toISOString();
|
|
107
|
+
switch (event.type) {
|
|
108
|
+
case 'message_received':
|
|
109
|
+
return mapMessageReceived(event.payload, timestamp, sensitivity);
|
|
110
|
+
case 'message_sent':
|
|
111
|
+
return mapMessageSent(event.payload, timestamp, sensitivity);
|
|
112
|
+
case 'session_start':
|
|
113
|
+
return mapSessionStart(timestamp, sensitivity);
|
|
114
|
+
case 'after_tool_call':
|
|
115
|
+
return mapToolCall(event.payload, timestamp, sensitivity);
|
|
116
|
+
case 'fact_extracted':
|
|
117
|
+
return mapFactExtracted(event.payload, timestamp, sensitivity);
|
|
118
|
+
case 'task_completed':
|
|
119
|
+
return mapTaskCompleted(event.payload, timestamp);
|
|
120
|
+
default:
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
package/dist/parser.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared record parser — used by both membrane_search tool and before_agent_start hook.
|
|
3
|
+
* Extracts and categorizes memories from Membrane gRPC Retrieve responses.
|
|
4
|
+
*
|
|
5
|
+
* Handles all 4 memory types: episodic, semantic, competence, working.
|
|
6
|
+
* Prioritizes user/assistant messages over tool calls.
|
|
7
|
+
*/
|
|
8
|
+
const MIN_SUMMARY_LENGTH = 5;
|
|
9
|
+
const MIN_TOOL_SUMMARY_LENGTH = 30;
|
|
10
|
+
const MAX_SUMMARY_LENGTH = 500;
|
|
11
|
+
/**
|
|
12
|
+
* Parse raw Membrane Retrieve records into categorized, formatted memory strings.
|
|
13
|
+
*/
|
|
14
|
+
export function parseMembraneRecords(rawRecords, logger) {
|
|
15
|
+
const conversational = [];
|
|
16
|
+
const tool = [];
|
|
17
|
+
for (const raw of rawRecords) {
|
|
18
|
+
try {
|
|
19
|
+
const record = JSON.parse(Buffer.from(raw).toString());
|
|
20
|
+
const tsShort = formatTimestamp(record.created_at);
|
|
21
|
+
parsePayload(record, tsShort, conversational, tool);
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
logger?.debug(`[membrane] Failed to parse record: ${err instanceof Error ? err.message : String(err)}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return { conversational, tool };
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Select final memories: prioritize conversational, fill remaining with tool memories.
|
|
31
|
+
*/
|
|
32
|
+
export function selectMemories(parsed, limit) {
|
|
33
|
+
const result = parsed.conversational.slice(0, limit);
|
|
34
|
+
if (result.length < limit) {
|
|
35
|
+
result.push(...parsed.tool.slice(0, limit - result.length));
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
// --- Internal helpers ---
|
|
40
|
+
function formatTimestamp(ts) {
|
|
41
|
+
if (!ts)
|
|
42
|
+
return '';
|
|
43
|
+
return ts.substring(0, 16).replace('T', ' ');
|
|
44
|
+
}
|
|
45
|
+
function parsePayload(record, tsShort, conversational, tool) {
|
|
46
|
+
const payload = record.payload;
|
|
47
|
+
switch (payload.kind) {
|
|
48
|
+
case 'episodic':
|
|
49
|
+
parseEpisodic(payload.timeline, tsShort, conversational, tool);
|
|
50
|
+
break;
|
|
51
|
+
case 'semantic':
|
|
52
|
+
parseSemantic(payload, tsShort, conversational);
|
|
53
|
+
break;
|
|
54
|
+
case 'competence':
|
|
55
|
+
parseCompetence(payload, tsShort, conversational);
|
|
56
|
+
break;
|
|
57
|
+
case 'working':
|
|
58
|
+
parseWorking(payload, tsShort, conversational);
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function parseEpisodic(timeline, tsShort, conversational, tool) {
|
|
63
|
+
if (!timeline?.length)
|
|
64
|
+
return;
|
|
65
|
+
for (const entry of timeline) {
|
|
66
|
+
const summary = entry.summary || '';
|
|
67
|
+
const kind = entry.event_kind || 'event';
|
|
68
|
+
if (summary.length < MIN_SUMMARY_LENGTH)
|
|
69
|
+
continue;
|
|
70
|
+
if (kind === 'tool_call' && summary.length < MIN_TOOL_SUMMARY_LENGTH)
|
|
71
|
+
continue;
|
|
72
|
+
const line = `[${kind} ${tsShort}] ${summary.substring(0, MAX_SUMMARY_LENGTH)}`;
|
|
73
|
+
if (kind === 'user_message' || kind === 'assistant_message' || kind === 'system_event') {
|
|
74
|
+
conversational.push(line);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
tool.push(line);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function parseSemantic(payload, tsShort, conversational) {
|
|
82
|
+
const { subject, predicate, object } = payload;
|
|
83
|
+
if (!subject && !predicate)
|
|
84
|
+
return;
|
|
85
|
+
const objStr = typeof object === 'string' ? object : JSON.stringify(object);
|
|
86
|
+
conversational.push(`[fact ${tsShort}] ${subject} ${predicate} ${objStr}`.substring(0, MAX_SUMMARY_LENGTH));
|
|
87
|
+
}
|
|
88
|
+
function parseCompetence(payload, tsShort, conversational) {
|
|
89
|
+
const desc = payload.description || payload.pattern || '';
|
|
90
|
+
if (desc) {
|
|
91
|
+
conversational.push(`[competence ${tsShort}] ${desc.substring(0, MAX_SUMMARY_LENGTH)}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function parseWorking(payload, tsShort, conversational) {
|
|
95
|
+
const summary = payload.context_summary || payload.state || '';
|
|
96
|
+
if (summary) {
|
|
97
|
+
conversational.push(`[working ${tsShort}] ${summary.substring(0, MAX_SUMMARY_LENGTH)}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type definitions for openclaw-membrane plugin.
|
|
3
|
+
* Eliminates `any` throughout the codebase.
|
|
4
|
+
*/
|
|
5
|
+
export const DEFAULT_CONFIG = {
|
|
6
|
+
grpc_endpoint: 'localhost:50051',
|
|
7
|
+
buffer_size: 1000,
|
|
8
|
+
default_sensitivity: 'low',
|
|
9
|
+
retrieve_enabled: true,
|
|
10
|
+
retrieve_limit: 5,
|
|
11
|
+
retrieve_min_salience: 0.1,
|
|
12
|
+
retrieve_max_sensitivity: 'medium',
|
|
13
|
+
retrieve_timeout_ms: 2000,
|
|
14
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "openclaw-membrane",
|
|
3
|
+
"name": "@vainplex/openclaw-membrane",
|
|
4
|
+
"description": "Membrane gRPC bridge for OpenClaw — episodic memory ingestion, search tool, and auto-context injection",
|
|
5
|
+
"version": "0.3.0",
|
|
6
|
+
"configSchema": {
|
|
7
|
+
"type": "object",
|
|
8
|
+
"additionalProperties": true,
|
|
9
|
+
"properties": {
|
|
10
|
+
"enabled": {
|
|
11
|
+
"type": "boolean",
|
|
12
|
+
"default": true
|
|
13
|
+
},
|
|
14
|
+
"grpc_endpoint": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"default": "localhost:50051"
|
|
17
|
+
},
|
|
18
|
+
"buffer_size": {
|
|
19
|
+
"type": "number",
|
|
20
|
+
"default": 1000
|
|
21
|
+
},
|
|
22
|
+
"default_sensitivity": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"default": "low",
|
|
25
|
+
"enum": ["public", "low", "medium", "high", "hyper"]
|
|
26
|
+
},
|
|
27
|
+
"retrieve_enabled": {
|
|
28
|
+
"type": "boolean",
|
|
29
|
+
"default": true
|
|
30
|
+
},
|
|
31
|
+
"retrieve_limit": {
|
|
32
|
+
"type": "number",
|
|
33
|
+
"default": 5
|
|
34
|
+
},
|
|
35
|
+
"retrieve_min_salience": {
|
|
36
|
+
"type": "number",
|
|
37
|
+
"default": 0.1
|
|
38
|
+
},
|
|
39
|
+
"retrieve_max_sensitivity": {
|
|
40
|
+
"type": "string",
|
|
41
|
+
"default": "medium",
|
|
42
|
+
"enum": ["public", "low", "medium", "high", "hyper"]
|
|
43
|
+
},
|
|
44
|
+
"retrieve_timeout_ms": {
|
|
45
|
+
"type": "number",
|
|
46
|
+
"default": 2000
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vainplex/openclaw-membrane",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Membrane gRPC bridge for OpenClaw — episodic memory ingestion, search tool, and auto-context injection via Membrane sidecar",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsc",
|
|
10
|
+
"test": "vitest run",
|
|
11
|
+
"dev": "vitest"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@grpc/grpc-js": "^1.14.3",
|
|
15
|
+
"@grpc/proto-loader": "^0.7.15",
|
|
16
|
+
"nats": "^2.15.0",
|
|
17
|
+
"zod": "^3.22.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/node": "^20.19.33",
|
|
21
|
+
"ts-node": "^10.9.2",
|
|
22
|
+
"typescript": "^5.9.3",
|
|
23
|
+
"vitest": "^1.0.0"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"openclaw",
|
|
27
|
+
"membrane",
|
|
28
|
+
"grpc",
|
|
29
|
+
"episodic-memory",
|
|
30
|
+
"plugin",
|
|
31
|
+
"agent-memory"
|
|
32
|
+
],
|
|
33
|
+
"author": "Vainplex <dev@vainplex.de>",
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"openclaw": {
|
|
36
|
+
"id": "openclaw-membrane",
|
|
37
|
+
"extensions": [
|
|
38
|
+
"./dist/index.js"
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "https://github.com/alberthild/openclaw-membrane"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/alberthild/openclaw-membrane#readme",
|
|
46
|
+
"files": [
|
|
47
|
+
"dist/",
|
|
48
|
+
"assets/",
|
|
49
|
+
"openclaw.plugin.json",
|
|
50
|
+
"README.md",
|
|
51
|
+
"CHANGELOG.md"
|
|
52
|
+
]
|
|
53
|
+
}
|