@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 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;
@@ -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
+ }