From b01cb3498f3cfd389a8eeb2e3156fb0a001a997e Mon Sep 17 00:00:00 2001 From: Claudia Date: Fri, 6 Mar 2026 11:10:57 +0100 Subject: [PATCH 01/10] feat: add OpenClaw client plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds clients/openclaw/ — an OpenClaw plugin that bridges to Membrane for episodic memory. Features: - Event ingestion via after_agent_reply / after_tool_call hooks - membrane_search tool for episodic memory queries - Auto-context injection before agent turns - /membrane status command - Full TypeScript, zero custom gRPC (uses @gustycube/membrane) Uses the official @gustycube/membrane TypeScript client as dependency. 10 tests passing (vitest). --- clients/openclaw/.gitignore | 2 + clients/openclaw/README.md | 110 ++++++++++++++++++ clients/openclaw/openclaw.plugin.json | 27 +++++ clients/openclaw/package.json | 44 ++++++++ clients/openclaw/src/index.ts | 154 ++++++++++++++++++++++++++ clients/openclaw/src/mapping.ts | 52 +++++++++ clients/openclaw/src/types.ts | 59 ++++++++++ clients/openclaw/test/mapping.test.ts | 82 ++++++++++++++ clients/openclaw/tsconfig.json | 18 +++ clients/openclaw/vitest.config.ts | 7 ++ 10 files changed, 555 insertions(+) create mode 100644 clients/openclaw/.gitignore create mode 100644 clients/openclaw/README.md create mode 100644 clients/openclaw/openclaw.plugin.json create mode 100644 clients/openclaw/package.json create mode 100644 clients/openclaw/src/index.ts create mode 100644 clients/openclaw/src/mapping.ts create mode 100644 clients/openclaw/src/types.ts create mode 100644 clients/openclaw/test/mapping.test.ts create mode 100644 clients/openclaw/tsconfig.json create mode 100644 clients/openclaw/vitest.config.ts diff --git a/clients/openclaw/.gitignore b/clients/openclaw/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/clients/openclaw/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/clients/openclaw/README.md b/clients/openclaw/README.md new file mode 100644 index 0000000..d8f9279 --- /dev/null +++ b/clients/openclaw/README.md @@ -0,0 +1,110 @@ +# OpenClaw Membrane Plugin + +[OpenClaw](https://github.com/openclaw/openclaw) plugin that bridges to [Membrane](https://github.com/GustyCube/membrane) — giving your AI agents episodic memory. + +## What it does + +- **Ingests** agent events, tool outputs, and observations into Membrane +- **Searches** episodic memory via the `membrane_search` tool +- **Auto-injects** relevant context before each agent turn +- **Reports** connection status via the `/membrane` command + +## Install + +```bash +# In your OpenClaw extensions directory +npm install @vainplex/openclaw-membrane +``` + +Or with [Brainplex](https://www.npmjs.com/package/brainplex): + +```bash +npx brainplex init # Auto-detects and configures all plugins +``` + +## Prerequisites + +- A running [Membrane](https://github.com/GustyCube/membrane) instance (the `membraned` daemon) +- OpenClaw v0.10+ + +## Configuration + +`~/.openclaw/plugins/openclaw-membrane/config.json`: + +```json +{ + "grpc_endpoint": "localhost:4222", + "default_sensitivity": "low", + "auto_context": true, + "context_limit": 5, + "min_salience": 0.3, + "context_types": ["event", "tool_output", "observation"] +} +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `grpc_endpoint` | `localhost:4222` | Membrane gRPC address | +| `default_sensitivity` | `low` | Sensitivity for ingested events: `public`, `low`, `medium`, `high`, `hyper` | +| `auto_context` | `true` | Auto-inject memories before each agent turn | +| `context_limit` | `5` | Max memories to inject | +| `min_salience` | `0.3` | Minimum salience score for retrieval | +| `context_types` | `["event", "tool_output", "observation"]` | Memory types to include | +| `buffer_size` | `100` | Event buffer for reliability | +| `flush_interval_ms` | `5000` | Buffer flush interval | + +## Usage + +### membrane_search tool + +Your agent can search episodic memory: + +``` +membrane_search("what happened in yesterday's meeting", { limit: 10 }) +``` + +### Auto-context + +When `auto_context: true`, the plugin injects relevant memories into the agent's context before each turn. This gives agents awareness of past interactions without explicit tool calls. + +### /membrane command + +Check connection status: + +``` +/membrane +→ Membrane: connected (localhost:4222) | 1,247 records | 3 memory types +``` + +## Architecture + +``` +OpenClaw Agent + │ + ├── after_agent_reply ──→ ingestEvent() + ├── after_tool_call ────→ ingestToolOutput() + ├── before_agent_start ─→ retrieve() → inject context + │ + └── membrane_search ───→ retrieve() → return results + │ + ▼ + Membrane (gRPC) + ┌─────────────┐ + │ membraned │ + │ SQLCipher │ + │ Embeddings │ + └─────────────┘ +``` + +## Development + +```bash +cd clients/openclaw +npm install +npm run build +npm test +``` + +## License + +MIT — see [LICENSE](../../LICENSE) diff --git a/clients/openclaw/openclaw.plugin.json b/clients/openclaw/openclaw.plugin.json new file mode 100644 index 0000000..f8aa3d0 --- /dev/null +++ b/clients/openclaw/openclaw.plugin.json @@ -0,0 +1,27 @@ +{ + "name": "openclaw-membrane", + "version": "0.4.0", + "description": "Membrane episodic memory bridge — ingest events, search memories, auto-inject context", + "hooks": [ + "after_agent_reply", + "after_tool_call", + "before_agent_start" + ], + "tools": [ + { + "name": "membrane_search", + "description": "Search episodic memory in Membrane for relevant context", + "parameters": { + "query": { "type": "string", "description": "Natural language query to search memories" }, + "limit": { "type": "number", "description": "Maximum results to return (default: 5)" }, + "memory_types": { "type": "array", "description": "Filter by memory type: event, tool_output, observation, working_state" } + } + } + ], + "commands": [ + { + "name": "membrane", + "description": "Show Membrane connection status and memory stats" + } + ] +} diff --git a/clients/openclaw/package.json b/clients/openclaw/package.json new file mode 100644 index 0000000..a6c6940 --- /dev/null +++ b/clients/openclaw/package.json @@ -0,0 +1,44 @@ +{ + "name": "@vainplex/openclaw-membrane", + "version": "0.4.0", + "description": "Membrane bridge plugin for OpenClaw — episodic memory ingestion, search, and auto-context injection", + "license": "MIT", + "author": "Vainplex ", + "repository": { + "type": "git", + "url": "https://github.com/GustyCube/membrane.git", + "directory": "clients/openclaw" + }, + "homepage": "https://github.com/GustyCube/membrane", + "bugs": { + "url": "https://github.com/GustyCube/membrane/issues" + }, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist", + "openclaw.plugin.json", + "README.md" + ], + "scripts": { + "build": "rm -rf dist && tsc", + "test": "vitest run" + }, + "dependencies": { + "@gustycube/membrane": "^0.1.4" + }, + "devDependencies": { + "typescript": "^5.7.0", + "vitest": "^3.0.0" + }, + "engines": { + "node": ">=20" + } +} diff --git a/clients/openclaw/src/index.ts b/clients/openclaw/src/index.ts new file mode 100644 index 0000000..e48e52b --- /dev/null +++ b/clients/openclaw/src/index.ts @@ -0,0 +1,154 @@ +/** + * @vainplex/openclaw-membrane — Membrane bridge plugin for OpenClaw + * + * Provides: + * - Event ingestion (write path) via @gustycube/membrane client + * - `membrane_search` tool for episodic memory queries + * - `before_agent_start` hook for auto-context injection + * - `/membrane` command for status and stats + */ + +import { MembraneClient, type MemoryRecord, type MemoryType, type RetrieveOptions } from "@gustycube/membrane"; +import { mapSensitivity, mapEventKind, summarize, buildTags } from "./mapping.js"; +import type { PluginConfig, PluginApi, PluginLogger, OpenClawEvent } from "./types.js"; +import { DEFAULT_CONFIG } from "./types.js"; + +// ── Config ── + +export function createConfig(raw: Record): PluginConfig { + return { ...DEFAULT_CONFIG, ...validateConfig(raw) }; +} + +export function validateConfig(raw: Record | undefined): Partial { + if (!raw) return {}; + const result: Partial = {}; + if (typeof raw.grpc_endpoint === "string") result.grpc_endpoint = raw.grpc_endpoint; + if (typeof raw.default_sensitivity === "string") result.default_sensitivity = raw.default_sensitivity; + if (typeof raw.buffer_size === "number") result.buffer_size = raw.buffer_size; + if (typeof raw.auto_context === "boolean") result.auto_context = raw.auto_context; + if (typeof raw.context_limit === "number") result.context_limit = raw.context_limit; + if (typeof raw.min_salience === "number") result.min_salience = raw.min_salience; + if (typeof raw.flush_interval_ms === "number") result.flush_interval_ms = raw.flush_interval_ms; + if (Array.isArray(raw.context_types)) { + result.context_types = raw.context_types.filter((t): t is string => typeof t === "string"); + } + return result; +} + +// ── Plugin Lifecycle ── + +let client: MembraneClient | null = null; +let config: PluginConfig = DEFAULT_CONFIG; +let log: PluginLogger = console; + +/** Initialize the plugin — called by OpenClaw on load */ +export function activate(api: PluginApi): void { + config = createConfig(api.config); + log = api.log; + + client = new MembraneClient(config.grpc_endpoint); + log.info(`[membrane] Connected to ${config.grpc_endpoint}`); +} + +/** Cleanup — called by OpenClaw on shutdown */ +export function deactivate(): void { + if (client) client.close(); + client = null; + log.info("[membrane] Disconnected"); +} + +// ── Hooks ── + +/** Ingest agent replies and tool outputs into Membrane */ +export async function handleEvent(event: OpenClawEvent): Promise { + if (!client) return; + + const kind = mapEventKind(event); + const sensitivity = mapSensitivity(config.default_sensitivity); + const tags = buildTags(event); + const source = event.agentId ?? "openclaw"; + + try { + if (kind === "tool_output" && event.toolName) { + await client.ingestToolOutput(event.toolName, { + args: (event.toolParams ?? {}) as Record, + result: event.toolResult ?? null, + sensitivity, + source, + tags, + }); + } else { + await client.ingestEvent(event.hook, source, { + summary: summarize(event), + sensitivity, + tags, + }); + } + } catch (err) { + log.warn(`[membrane] Ingestion failed: ${err instanceof Error ? err.message : String(err)}`); + } +} + +/** Search Membrane for relevant memories (exposed as membrane_search tool) */ +export async function search( + query: string, + options?: { limit?: number; memoryTypes?: string[]; minSalience?: number }, +): Promise { + if (!client) return []; + + try { + const retrieveOpts: RetrieveOptions = { + limit: options?.limit ?? config.context_limit, + minSalience: options?.minSalience ?? config.min_salience, + }; + if (options?.memoryTypes) { + retrieveOpts.memoryTypes = options.memoryTypes as MemoryType[]; + } + return await client.retrieve(query, retrieveOpts); + } catch (err) { + log.warn(`[membrane] Search failed: ${err instanceof Error ? err.message : String(err)}`); + return []; + } +} + +/** Auto-inject context before agent starts (if enabled) */ +export async function getContext(agentId: string): Promise { + if (!config.auto_context || !client) return null; + + try { + const records = await client.retrieve(`context for agent ${agentId}`, { + limit: config.context_limit, + memoryTypes: config.context_types as MemoryType[], + minSalience: config.min_salience, + }); + + if (records.length === 0) return null; + + const lines = records.map((r: MemoryRecord, i: number) => + `${i + 1}. [${r.type}] ${r.id}` + ); + return `Episodic memory from Membrane:\n${lines.join("\n")}`; + } catch (err) { + log.debug(`[membrane] Context injection skipped: ${err instanceof Error ? err.message : String(err)}`); + return null; + } +} + +/** Get connection status and stats */ +export async function getStatus(): Promise<{ connected: boolean; endpoint: string; metrics?: unknown }> { + if (!client) { + return { connected: false, endpoint: config.grpc_endpoint }; + } + + try { + const metrics = await client.getMetrics(); + return { connected: true, endpoint: config.grpc_endpoint, metrics }; + } catch { + return { connected: false, endpoint: config.grpc_endpoint }; + } +} + +// Re-exports +export type { PluginConfig, PluginApi, PluginLogger, OpenClawEvent } from "./types.js"; +export { DEFAULT_CONFIG } from "./types.js"; +export { mapSensitivity, mapEventKind, summarize, buildTags } from "./mapping.js"; diff --git a/clients/openclaw/src/mapping.ts b/clients/openclaw/src/mapping.ts new file mode 100644 index 0000000..8ae7548 --- /dev/null +++ b/clients/openclaw/src/mapping.ts @@ -0,0 +1,52 @@ +/** + * Maps OpenClaw events to Membrane ingestion formats. + */ + +import type { OpenClawEvent } from "./types.js"; + +/** Map OpenClaw sensitivity strings to Membrane sensitivity levels */ +export function mapSensitivity(level: string): string { + const map: Record = { + public: "public", + low: "low", + medium: "medium", + high: "high", + hyper: "hyper", + }; + return map[level] ?? "low"; +} + +/** Determine the best Membrane ingestion method for an OpenClaw event */ +export function mapEventKind(event: OpenClawEvent): "event" | "tool_output" | "observation" { + if (event.hook === "after_tool_call" && event.toolName) { + return "tool_output"; + } + if (event.hook === "after_agent_reply") { + return "event"; + } + return "observation"; +} + +/** Extract a human-readable summary from an OpenClaw event */ +export function summarize(event: OpenClawEvent): string { + if (event.hook === "after_tool_call" && event.toolName) { + const args = event.toolParams + ? Object.keys(event.toolParams).join(", ") + : ""; + return `Tool call: ${event.toolName}(${args})`; + } + if (event.hook === "after_agent_reply" && event.response) { + const preview = event.response.slice(0, 200); + return `Agent reply: ${preview}${event.response.length > 200 ? "..." : ""}`; + } + return `Event: ${event.hook}`; +} + +/** Build tags from an OpenClaw event */ +export function buildTags(event: OpenClawEvent): string[] { + const tags: string[] = [`hook:${event.hook}`]; + if (event.agentId) tags.push(`agent:${event.agentId}`); + if (event.toolName) tags.push(`tool:${event.toolName}`); + if (event.sessionKey) tags.push(`session:${event.sessionKey}`); + return tags; +} diff --git a/clients/openclaw/src/types.ts b/clients/openclaw/src/types.ts new file mode 100644 index 0000000..351e0d9 --- /dev/null +++ b/clients/openclaw/src/types.ts @@ -0,0 +1,59 @@ +/** + * Types for the OpenClaw Membrane plugin. + */ + +export interface PluginConfig { + /** Membrane gRPC endpoint (default: localhost:4222) */ + grpc_endpoint: string; + /** Default sensitivity for ingested events */ + default_sensitivity: string; + /** Event buffer size for reliability */ + buffer_size: number; + /** Auto-inject context on agent start */ + auto_context: boolean; + /** Max memories to inject as context */ + context_limit: number; + /** Min salience for context injection */ + min_salience: number; + /** Memory types to include in context */ + context_types: string[]; + /** Flush interval in ms for buffered events */ + flush_interval_ms: number; +} + +export const DEFAULT_CONFIG: PluginConfig = { + grpc_endpoint: "localhost:4222", + default_sensitivity: "low", + buffer_size: 100, + auto_context: true, + context_limit: 5, + min_salience: 0.3, + context_types: ["event", "tool_output", "observation"], + flush_interval_ms: 5000, +}; + +/** OpenClaw hook event passed to plugin hooks */ +export interface OpenClawEvent { + hook: string; + agentId?: string; + sessionKey?: string; + toolName?: string; + toolParams?: Record; + toolResult?: unknown; + message?: string; + response?: string; + timestamp?: string; +} + +/** OpenClaw plugin API interface */ +export interface PluginApi { + log: PluginLogger; + config: Record; +} + +export interface PluginLogger { + info(msg: string, ...args: unknown[]): void; + warn(msg: string, ...args: unknown[]): void; + error(msg: string, ...args: unknown[]): void; + debug(msg: string, ...args: unknown[]): void; +} diff --git a/clients/openclaw/test/mapping.test.ts b/clients/openclaw/test/mapping.test.ts new file mode 100644 index 0000000..5824498 --- /dev/null +++ b/clients/openclaw/test/mapping.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from "vitest"; +import { mapSensitivity, mapEventKind, summarize, buildTags } from "../src/mapping.js"; +import type { OpenClawEvent } from "../src/types.js"; + +describe("mapSensitivity", () => { + it("maps known levels", () => { + expect(mapSensitivity("public")).toBe("public"); + expect(mapSensitivity("high")).toBe("high"); + expect(mapSensitivity("hyper")).toBe("hyper"); + }); + + it("defaults unknown to low", () => { + expect(mapSensitivity("unknown")).toBe("low"); + expect(mapSensitivity("")).toBe("low"); + }); +}); + +describe("mapEventKind", () => { + it("maps tool calls to tool_output", () => { + const event: OpenClawEvent = { hook: "after_tool_call", toolName: "exec" }; + expect(mapEventKind(event)).toBe("tool_output"); + }); + + it("maps agent replies to event", () => { + const event: OpenClawEvent = { hook: "after_agent_reply", response: "Hello" }; + expect(mapEventKind(event)).toBe("event"); + }); + + it("defaults to observation", () => { + const event: OpenClawEvent = { hook: "before_agent_start" }; + expect(mapEventKind(event)).toBe("observation"); + }); +}); + +describe("summarize", () => { + it("summarizes tool calls", () => { + const event: OpenClawEvent = { + hook: "after_tool_call", + toolName: "exec", + toolParams: { command: "ls" }, + }; + expect(summarize(event)).toBe("Tool call: exec(command)"); + }); + + it("summarizes agent replies with truncation", () => { + const event: OpenClawEvent = { + hook: "after_agent_reply", + response: "x".repeat(300), + }; + const result = summarize(event); + expect(result).toContain("Agent reply:"); + expect(result).toContain("..."); + expect(result.length).toBeLessThan(250); + }); + + it("handles generic events", () => { + const event: OpenClawEvent = { hook: "unknown_hook" }; + expect(summarize(event)).toBe("Event: unknown_hook"); + }); +}); + +describe("buildTags", () => { + it("builds tags from event", () => { + const event: OpenClawEvent = { + hook: "after_tool_call", + agentId: "main", + toolName: "exec", + sessionKey: "agent:main:main", + }; + const tags = buildTags(event); + expect(tags).toContain("hook:after_tool_call"); + expect(tags).toContain("agent:main"); + expect(tags).toContain("tool:exec"); + expect(tags).toContain("session:agent:main:main"); + }); + + it("omits missing fields", () => { + const event: OpenClawEvent = { hook: "test" }; + const tags = buildTags(event); + expect(tags).toEqual(["hook:test"]); + }); +}); diff --git a/clients/openclaw/tsconfig.json b/clients/openclaw/tsconfig.json new file mode 100644 index 0000000..8310040 --- /dev/null +++ b/clients/openclaw/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/clients/openclaw/vitest.config.ts b/clients/openclaw/vitest.config.ts new file mode 100644 index 0000000..8b5840a --- /dev/null +++ b/clients/openclaw/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + }, +}); From 07722e482071147ad415e28e314de90fb2b86c4f Mon Sep 17 00:00:00 2001 From: Claudia Date: Fri, 6 Mar 2026 11:28:23 +0100 Subject: [PATCH 02/10] refactor: class-based plugin, add plugin tests BREAKING: Replace module-level singletons with OpenClawMembranePlugin class. - Prevents gRPC connection leaks on re-activation - Enables proper unit testing without global state mutation - Add test/plugin.test.ts (11 tests for config, lifecycle, error paths) Total: 21 tests passing, zero TypeScript errors. --- .gitignore | 1 + clients/openclaw/src/index.ts | 205 ++++++++++++++------------- clients/openclaw/test/plugin.test.ts | 90 ++++++++++++ 3 files changed, 199 insertions(+), 97 deletions(-) create mode 100644 clients/openclaw/test/plugin.test.ts diff --git a/.gitignore b/.gitignore index 5e8fda2..cf262a4 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,4 @@ node_modules/ # Plan files plan.md .claude/ +clients/openclaw/package-lock.json diff --git a/clients/openclaw/src/index.ts b/clients/openclaw/src/index.ts index e48e52b..e5e573f 100644 --- a/clients/openclaw/src/index.ts +++ b/clients/openclaw/src/index.ts @@ -35,116 +35,127 @@ export function validateConfig(raw: Record | undefined): Partia return result; } -// ── Plugin Lifecycle ── +// ── Plugin Class ── -let client: MembraneClient | null = null; -let config: PluginConfig = DEFAULT_CONFIG; -let log: PluginLogger = console; - -/** Initialize the plugin — called by OpenClaw on load */ -export function activate(api: PluginApi): void { - config = createConfig(api.config); - log = api.log; - - client = new MembraneClient(config.grpc_endpoint); - log.info(`[membrane] Connected to ${config.grpc_endpoint}`); -} +/** + * OpenClaw plugin bridge to Membrane episodic memory. + * Each instance owns its own client and config — no module-level singletons. + */ +export class OpenClawMembranePlugin { + private client: MembraneClient | null = null; + private config: PluginConfig; + private log: PluginLogger; + + constructor(api: PluginApi) { + this.config = createConfig(api.config); + this.log = api.log; + } -/** Cleanup — called by OpenClaw on shutdown */ -export function deactivate(): void { - if (client) client.close(); - client = null; - log.info("[membrane] Disconnected"); -} + /** Connect to Membrane */ + activate(): void { + if (this.client) { + this.client.close(); + } + this.client = new MembraneClient(this.config.grpc_endpoint); + this.log.info(`[membrane] Connected to ${this.config.grpc_endpoint}`); + } -// ── Hooks ── - -/** Ingest agent replies and tool outputs into Membrane */ -export async function handleEvent(event: OpenClawEvent): Promise { - if (!client) return; - - const kind = mapEventKind(event); - const sensitivity = mapSensitivity(config.default_sensitivity); - const tags = buildTags(event); - const source = event.agentId ?? "openclaw"; - - try { - if (kind === "tool_output" && event.toolName) { - await client.ingestToolOutput(event.toolName, { - args: (event.toolParams ?? {}) as Record, - result: event.toolResult ?? null, - sensitivity, - source, - tags, - }); - } else { - await client.ingestEvent(event.hook, source, { - summary: summarize(event), - sensitivity, - tags, - }); + /** Disconnect from Membrane */ + deactivate(): void { + if (this.client) { + this.client.close(); + this.client = null; } - } catch (err) { - log.warn(`[membrane] Ingestion failed: ${err instanceof Error ? err.message : String(err)}`); + this.log.info("[membrane] Disconnected"); } -} -/** Search Membrane for relevant memories (exposed as membrane_search tool) */ -export async function search( - query: string, - options?: { limit?: number; memoryTypes?: string[]; minSalience?: number }, -): Promise { - if (!client) return []; - - try { - const retrieveOpts: RetrieveOptions = { - limit: options?.limit ?? config.context_limit, - minSalience: options?.minSalience ?? config.min_salience, - }; - if (options?.memoryTypes) { - retrieveOpts.memoryTypes = options.memoryTypes as MemoryType[]; + /** Ingest agent replies and tool outputs into Membrane */ + async handleEvent(event: OpenClawEvent): Promise { + if (!this.client) return; + + const kind = mapEventKind(event); + const sensitivity = mapSensitivity(this.config.default_sensitivity); + const tags = buildTags(event); + const source = event.agentId ?? "openclaw"; + + try { + if (kind === "tool_output" && event.toolName) { + await this.client.ingestToolOutput(event.toolName, { + args: (event.toolParams ?? {}) as Record, + result: event.toolResult ?? null, + sensitivity, + source, + tags, + }); + } else { + await this.client.ingestEvent(event.hook, source, { + summary: summarize(event), + sensitivity, + tags, + }); + } + } catch (err) { + this.log.warn(`[membrane] Ingestion failed: ${err instanceof Error ? err.message : String(err)}`); } - return await client.retrieve(query, retrieveOpts); - } catch (err) { - log.warn(`[membrane] Search failed: ${err instanceof Error ? err.message : String(err)}`); - return []; } -} -/** Auto-inject context before agent starts (if enabled) */ -export async function getContext(agentId: string): Promise { - if (!config.auto_context || !client) return null; - - try { - const records = await client.retrieve(`context for agent ${agentId}`, { - limit: config.context_limit, - memoryTypes: config.context_types as MemoryType[], - minSalience: config.min_salience, - }); - - if (records.length === 0) return null; - - const lines = records.map((r: MemoryRecord, i: number) => - `${i + 1}. [${r.type}] ${r.id}` - ); - return `Episodic memory from Membrane:\n${lines.join("\n")}`; - } catch (err) { - log.debug(`[membrane] Context injection skipped: ${err instanceof Error ? err.message : String(err)}`); - return null; + /** Search Membrane for relevant memories */ + async search( + query: string, + options?: { limit?: number; memoryTypes?: string[]; minSalience?: number }, + ): Promise { + if (!this.client) return []; + + try { + const retrieveOpts: RetrieveOptions = { + limit: options?.limit ?? this.config.context_limit, + minSalience: options?.minSalience ?? this.config.min_salience, + }; + if (options?.memoryTypes) { + retrieveOpts.memoryTypes = options.memoryTypes as MemoryType[]; + } + return await this.client.retrieve(query, retrieveOpts); + } catch (err) { + this.log.warn(`[membrane] Search failed: ${err instanceof Error ? err.message : String(err)}`); + return []; + } } -} -/** Get connection status and stats */ -export async function getStatus(): Promise<{ connected: boolean; endpoint: string; metrics?: unknown }> { - if (!client) { - return { connected: false, endpoint: config.grpc_endpoint }; + /** Auto-inject context before agent starts */ + async getContext(agentId: string): Promise { + if (!this.config.auto_context || !this.client) return null; + + try { + const records = await this.client.retrieve(`context for agent ${agentId}`, { + limit: this.config.context_limit, + memoryTypes: this.config.context_types as MemoryType[], + minSalience: this.config.min_salience, + }); + + if (records.length === 0) return null; + + const lines = records.map((r: MemoryRecord, i: number) => + `${i + 1}. [${r.type}] ${r.id}` + ); + return `Episodic memory from Membrane:\n${lines.join("\n")}`; + } catch (err) { + this.log.debug(`[membrane] Context injection skipped: ${err instanceof Error ? err.message : String(err)}`); + return null; + } } - try { - const metrics = await client.getMetrics(); - return { connected: true, endpoint: config.grpc_endpoint, metrics }; - } catch { - return { connected: false, endpoint: config.grpc_endpoint }; + /** Get connection status and stats */ + async getStatus(): Promise<{ connected: boolean; endpoint: string; metrics?: unknown }> { + if (!this.client) { + return { connected: false, endpoint: this.config.grpc_endpoint }; + } + + try { + const metrics = await this.client.getMetrics(); + return { connected: true, endpoint: this.config.grpc_endpoint, metrics }; + } catch { + return { connected: false, endpoint: this.config.grpc_endpoint }; + } } } diff --git a/clients/openclaw/test/plugin.test.ts b/clients/openclaw/test/plugin.test.ts new file mode 100644 index 0000000..8a4bc83 --- /dev/null +++ b/clients/openclaw/test/plugin.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { OpenClawMembranePlugin, createConfig, validateConfig, DEFAULT_CONFIG } from "../src/index.js"; +import type { PluginApi, OpenClawEvent } from "../src/types.js"; + +function mockApi(overrides: Record = {}): PluginApi { + return { + config: { grpc_endpoint: "localhost:4222", ...overrides }, + log: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + }; +} + +describe("createConfig", () => { + it("returns defaults when raw is empty", () => { + const config = createConfig({}); + expect(config.grpc_endpoint).toBe("localhost:4222"); + expect(config.auto_context).toBe(true); + expect(config.context_limit).toBe(5); + }); + + it("merges user config over defaults", () => { + const config = createConfig({ context_limit: 10, auto_context: false }); + expect(config.context_limit).toBe(10); + expect(config.auto_context).toBe(false); + expect(config.grpc_endpoint).toBe("localhost:4222"); // default preserved + }); + + it("ignores invalid types", () => { + const config = createConfig({ buffer_size: "not-a-number" }); + expect(config.buffer_size).toBe(DEFAULT_CONFIG.buffer_size); + }); +}); + +describe("validateConfig", () => { + it("returns empty for undefined input", () => { + expect(validateConfig(undefined)).toEqual({}); + }); + + it("filters context_types to strings only", () => { + const result = validateConfig({ context_types: ["event", 42, "observation"] }); + expect(result.context_types).toEqual(["event", "observation"]); + }); +}); + +describe("OpenClawMembranePlugin", () => { + let plugin: OpenClawMembranePlugin; + let api: PluginApi; + + beforeEach(() => { + api = mockApi(); + plugin = new OpenClawMembranePlugin(api); + }); + + it("constructs without activating", async () => { + // Plugin created but not connected — search should return empty + const result = await plugin.search("test"); + expect(result).toEqual([]); + }); + + it("deactivate is safe without activate", () => { + expect(() => plugin.deactivate()).not.toThrow(); + expect(api.log.info).toHaveBeenCalledWith("[membrane] Disconnected"); + }); + + it("getContext returns null when auto_context disabled", async () => { + const disabledApi = mockApi({ auto_context: false }); + const p = new OpenClawMembranePlugin(disabledApi); + expect(await p.getContext("test-agent")).toBeNull(); + }); + + it("getContext returns null when not activated", async () => { + expect(await plugin.getContext("test-agent")).toBeNull(); + }); + + it("getStatus returns disconnected when not activated", async () => { + const status = await plugin.getStatus(); + expect(status.connected).toBe(false); + expect(status.endpoint).toBe("localhost:4222"); + }); + + it("handleEvent is a no-op when not activated", async () => { + const event: OpenClawEvent = { hook: "after_agent_reply", response: "Hello" }; + // Should not throw + await plugin.handleEvent(event); + }); +}); From 4eba17d2e41e16f1cbb90ad29668a5d699f3cfdf Mon Sep 17 00:00:00 2001 From: Claudia Date: Fri, 6 Mar 2026 18:12:45 +0100 Subject: [PATCH 03/10] fix: address all 4 Copilot review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Observation routing: handleEvent now calls ingestObservation() for observation-kind events instead of misrouting through ingestEvent() 2. ingestEvent ref parameter: pass sessionKey (or hook name) as ref, set source explicitly in options — fixes wrong ref/source values 3. Snake_case support: search() now accepts both memory_types and memoryTypes, min_salience and minSalience — matches tool manifest 4. Remove dead config: buffer_size and flush_interval_ms removed from PluginConfig, DEFAULT_CONFIG, validateConfig, and README — the plugin does not implement buffering 22 tests passing. --- clients/openclaw/README.md | 3 +-- clients/openclaw/openclaw.plugin.json | 3 ++- clients/openclaw/src/index.ts | 33 +++++++++++++++++++++------ clients/openclaw/src/types.ts | 6 ----- clients/openclaw/test/mapping.test.ts | 7 +++++- clients/openclaw/test/plugin.test.ts | 4 ++-- 6 files changed, 37 insertions(+), 19 deletions(-) diff --git a/clients/openclaw/README.md b/clients/openclaw/README.md index d8f9279..7b555ca 100644 --- a/clients/openclaw/README.md +++ b/clients/openclaw/README.md @@ -50,8 +50,7 @@ npx brainplex init # Auto-detects and configures all plugins | `context_limit` | `5` | Max memories to inject | | `min_salience` | `0.3` | Minimum salience score for retrieval | | `context_types` | `["event", "tool_output", "observation"]` | Memory types to include | -| `buffer_size` | `100` | Event buffer for reliability | -| `flush_interval_ms` | `5000` | Buffer flush interval | + ## Usage diff --git a/clients/openclaw/openclaw.plugin.json b/clients/openclaw/openclaw.plugin.json index f8aa3d0..d755439 100644 --- a/clients/openclaw/openclaw.plugin.json +++ b/clients/openclaw/openclaw.plugin.json @@ -14,7 +14,8 @@ "parameters": { "query": { "type": "string", "description": "Natural language query to search memories" }, "limit": { "type": "number", "description": "Maximum results to return (default: 5)" }, - "memory_types": { "type": "array", "description": "Filter by memory type: event, tool_output, observation, working_state" } + "memory_types": { "type": "array", "description": "Filter by memory type: event, tool_output, observation, working_state" }, + "min_salience": { "type": "number", "description": "Minimum salience score (0-1, default: 0.3)" } } } ], diff --git a/clients/openclaw/src/index.ts b/clients/openclaw/src/index.ts index e5e573f..dd762ce 100644 --- a/clients/openclaw/src/index.ts +++ b/clients/openclaw/src/index.ts @@ -24,11 +24,9 @@ export function validateConfig(raw: Record | undefined): Partia const result: Partial = {}; if (typeof raw.grpc_endpoint === "string") result.grpc_endpoint = raw.grpc_endpoint; if (typeof raw.default_sensitivity === "string") result.default_sensitivity = raw.default_sensitivity; - if (typeof raw.buffer_size === "number") result.buffer_size = raw.buffer_size; if (typeof raw.auto_context === "boolean") result.auto_context = raw.auto_context; if (typeof raw.context_limit === "number") result.context_limit = raw.context_limit; if (typeof raw.min_salience === "number") result.min_salience = raw.min_salience; - if (typeof raw.flush_interval_ms === "number") result.flush_interval_ms = raw.flush_interval_ms; if (Array.isArray(raw.context_types)) { result.context_types = raw.context_types.filter((t): t is string => typeof t === "string"); } @@ -87,10 +85,21 @@ export class OpenClawMembranePlugin { source, tags, }); + } else if (kind === "observation") { + // Observation: subject=agentId, predicate=hook, obj=summary + await this.client.ingestObservation( + source, + event.hook, + summarize(event), + { sensitivity, source, tags }, + ); } else { - await this.client.ingestEvent(event.hook, source, { + // Event: ref = sessionKey or hook (unique reference for the event) + const ref = event.sessionKey ?? event.hook; + await this.client.ingestEvent(event.hook, ref, { summary: summarize(event), sensitivity, + source, tags, }); } @@ -102,17 +111,27 @@ export class OpenClawMembranePlugin { /** Search Membrane for relevant memories */ async search( query: string, - options?: { limit?: number; memoryTypes?: string[]; minSalience?: number }, + options?: { + limit?: number; + memoryTypes?: string[]; + memory_types?: string[]; + minSalience?: number; + min_salience?: number; + }, ): Promise { if (!this.client) return []; try { + const effectiveMemoryTypes = options?.memoryTypes ?? options?.memory_types; + const effectiveMinSalience = + options?.minSalience ?? options?.min_salience ?? this.config.min_salience; + const retrieveOpts: RetrieveOptions = { limit: options?.limit ?? this.config.context_limit, - minSalience: options?.minSalience ?? this.config.min_salience, + minSalience: effectiveMinSalience, }; - if (options?.memoryTypes) { - retrieveOpts.memoryTypes = options.memoryTypes as MemoryType[]; + if (effectiveMemoryTypes) { + retrieveOpts.memoryTypes = effectiveMemoryTypes as MemoryType[]; } return await this.client.retrieve(query, retrieveOpts); } catch (err) { diff --git a/clients/openclaw/src/types.ts b/clients/openclaw/src/types.ts index 351e0d9..63426ee 100644 --- a/clients/openclaw/src/types.ts +++ b/clients/openclaw/src/types.ts @@ -7,8 +7,6 @@ export interface PluginConfig { grpc_endpoint: string; /** Default sensitivity for ingested events */ default_sensitivity: string; - /** Event buffer size for reliability */ - buffer_size: number; /** Auto-inject context on agent start */ auto_context: boolean; /** Max memories to inject as context */ @@ -17,19 +15,15 @@ export interface PluginConfig { min_salience: number; /** Memory types to include in context */ context_types: string[]; - /** Flush interval in ms for buffered events */ - flush_interval_ms: number; } export const DEFAULT_CONFIG: PluginConfig = { grpc_endpoint: "localhost:4222", default_sensitivity: "low", - buffer_size: 100, auto_context: true, context_limit: 5, min_salience: 0.3, context_types: ["event", "tool_output", "observation"], - flush_interval_ms: 5000, }; /** OpenClaw hook event passed to plugin hooks */ diff --git a/clients/openclaw/test/mapping.test.ts b/clients/openclaw/test/mapping.test.ts index 5824498..a3215c5 100644 --- a/clients/openclaw/test/mapping.test.ts +++ b/clients/openclaw/test/mapping.test.ts @@ -26,10 +26,15 @@ describe("mapEventKind", () => { expect(mapEventKind(event)).toBe("event"); }); - it("defaults to observation", () => { + it("maps before_agent_start to observation", () => { const event: OpenClawEvent = { hook: "before_agent_start" }; expect(mapEventKind(event)).toBe("observation"); }); + + it("maps after_tool_call without toolName to observation", () => { + const event: OpenClawEvent = { hook: "after_tool_call" }; + expect(mapEventKind(event)).toBe("observation"); + }); }); describe("summarize", () => { diff --git a/clients/openclaw/test/plugin.test.ts b/clients/openclaw/test/plugin.test.ts index 8a4bc83..5ff4dec 100644 --- a/clients/openclaw/test/plugin.test.ts +++ b/clients/openclaw/test/plugin.test.ts @@ -30,8 +30,8 @@ describe("createConfig", () => { }); it("ignores invalid types", () => { - const config = createConfig({ buffer_size: "not-a-number" }); - expect(config.buffer_size).toBe(DEFAULT_CONFIG.buffer_size); + const config = createConfig({ context_limit: "not-a-number" }); + expect(config.context_limit).toBe(DEFAULT_CONFIG.context_limit); }); }); From 3efcd73b1bb4c7e86b6ada68b3fcbf62214cd8a3 Mon Sep 17 00:00:00 2001 From: Claudia Date: Thu, 12 Mar 2026 12:05:41 +0100 Subject: [PATCH 04/10] fix: address CodeRabbit and Copilot review findings - Restore .coderabbit.yaml (accidentally deleted) - Restore --version flag in cmd/membraned/main.go (accidentally reverted) - Fix openclaw.plugin.json to use proper JSON Schema with required - Harden validateConfig against NaN, negative, OOB values - Make event ref unique (timestamp-based) to avoid collisions - Show payload summary in context injection instead of just record ID - Fix getStatus() false disconnect on metrics failure - Add language specifiers to README code blocks - Add validation edge case tests (26/26 pass) --- .coderabbit.yaml | 40 +++++++++++++++++++++++++++ clients/openclaw/README.md | 6 ++-- clients/openclaw/openclaw.plugin.json | 12 +++++--- clients/openclaw/src/index.ts | 33 +++++++++++++++------- clients/openclaw/test/plugin.test.ts | 24 ++++++++++++++++ cmd/membraned/main.go | 9 ++++++ 6 files changed, 107 insertions(+), 17 deletions(-) create mode 100644 .coderabbit.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..30aeb96 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,40 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +language: "en-US" + +reviews: + profile: "assertive" + high_level_summary: true + auto_review: + enabled: true + drafts: false + ignore_title_keywords: + - "wip" + - "draft" + auto_pause_after_reviewed_commits: 0 + path_filters: + - "!go.sum" + - "!**/package-lock.json" + - "!**/pnpm-lock.yaml" + - "!**/yarn.lock" + - "!**/*.snap" + - "!**/dist/**" + - "!**/build/**" + - "!**/.vitepress/dist/**" + - "!**/coverage/**" + - "!**/.next/**" + - "!**/site/**" + - "!**/docs/.vitepress/cache/**" + - "!**/*.min.js" + - "!**/*.generated.*" + - "!**/*pb.go" + +chat: + auto_reply: true + +knowledge_base: + code_guidelines: + enabled: true + filePatterns: + - "CONTRIBUTING.md" + - "README.md" + - "docs/**/*.md" diff --git a/clients/openclaw/README.md b/clients/openclaw/README.md index 7b555ca..8e648b1 100644 --- a/clients/openclaw/README.md +++ b/clients/openclaw/README.md @@ -58,7 +58,7 @@ npx brainplex init # Auto-detects and configures all plugins Your agent can search episodic memory: -``` +```javascript membrane_search("what happened in yesterday's meeting", { limit: 10 }) ``` @@ -70,14 +70,14 @@ When `auto_context: true`, the plugin injects relevant memories into the agent's Check connection status: -``` +```text /membrane → Membrane: connected (localhost:4222) | 1,247 records | 3 memory types ``` ## Architecture -``` +```text OpenClaw Agent │ ├── after_agent_reply ──→ ingestEvent() diff --git a/clients/openclaw/openclaw.plugin.json b/clients/openclaw/openclaw.plugin.json index d755439..071de54 100644 --- a/clients/openclaw/openclaw.plugin.json +++ b/clients/openclaw/openclaw.plugin.json @@ -12,10 +12,14 @@ "name": "membrane_search", "description": "Search episodic memory in Membrane for relevant context", "parameters": { - "query": { "type": "string", "description": "Natural language query to search memories" }, - "limit": { "type": "number", "description": "Maximum results to return (default: 5)" }, - "memory_types": { "type": "array", "description": "Filter by memory type: event, tool_output, observation, working_state" }, - "min_salience": { "type": "number", "description": "Minimum salience score (0-1, default: 0.3)" } + "type": "object", + "properties": { + "query": { "type": "string", "description": "Natural language query to search memories" }, + "limit": { "type": "number", "description": "Maximum results to return (default: 5)" }, + "memory_types": { "type": "array", "description": "Filter by memory type: event, tool_output, observation, working_state" }, + "min_salience": { "type": "number", "description": "Minimum salience score (0-1, default: 0.3)" } + }, + "required": ["query"] } } ], diff --git a/clients/openclaw/src/index.ts b/clients/openclaw/src/index.ts index dd762ce..f46af50 100644 --- a/clients/openclaw/src/index.ts +++ b/clients/openclaw/src/index.ts @@ -25,10 +25,15 @@ export function validateConfig(raw: Record | undefined): Partia if (typeof raw.grpc_endpoint === "string") result.grpc_endpoint = raw.grpc_endpoint; if (typeof raw.default_sensitivity === "string") result.default_sensitivity = raw.default_sensitivity; if (typeof raw.auto_context === "boolean") result.auto_context = raw.auto_context; - if (typeof raw.context_limit === "number") result.context_limit = raw.context_limit; - if (typeof raw.min_salience === "number") result.min_salience = raw.min_salience; + if (typeof raw.context_limit === "number" && Number.isInteger(raw.context_limit) && raw.context_limit > 0) { + result.context_limit = raw.context_limit; + } + if (typeof raw.min_salience === "number" && Number.isFinite(raw.min_salience) && raw.min_salience >= 0 && raw.min_salience <= 1) { + result.min_salience = raw.min_salience; + } if (Array.isArray(raw.context_types)) { - result.context_types = raw.context_types.filter((t): t is string => typeof t === "string"); + const filtered = raw.context_types.filter((t): t is string => typeof t === "string"); + if (filtered.length > 0) result.context_types = filtered; } return result; } @@ -94,8 +99,12 @@ export class OpenClawMembranePlugin { { sensitivity, source, tags }, ); } else { - // Event: ref = sessionKey or hook (unique reference for the event) - const ref = event.sessionKey ?? event.hook; + // Event: ref must be unique per ingestion to avoid collisions + const ref = [ + event.sessionKey ?? source, + event.hook, + event.timestamp ?? String(Date.now()), + ].join(":"); await this.client.ingestEvent(event.hook, ref, { summary: summarize(event), sensitivity, @@ -153,9 +162,12 @@ export class OpenClawMembranePlugin { if (records.length === 0) return null; - const lines = records.map((r: MemoryRecord, i: number) => - `${i + 1}. [${r.type}] ${r.id}` - ); + const lines = records.map((r: MemoryRecord, i: number) => { + // Extract human-readable summary from payload when available + const payload = r.payload as Record | undefined; + const summary = payload?.summary ?? payload?.content ?? r.id; + return `${i + 1}. [${r.type}] ${String(summary)}`; + }); return `Episodic memory from Membrane:\n${lines.join("\n")}`; } catch (err) { this.log.debug(`[membrane] Context injection skipped: ${err instanceof Error ? err.message : String(err)}`); @@ -172,8 +184,9 @@ export class OpenClawMembranePlugin { try { const metrics = await this.client.getMetrics(); return { connected: true, endpoint: this.config.grpc_endpoint, metrics }; - } catch { - return { connected: false, endpoint: this.config.grpc_endpoint }; + } catch (err) { + this.log.debug(`[membrane] Metrics unavailable: ${err instanceof Error ? err.message : String(err)}`); + return { connected: true, endpoint: this.config.grpc_endpoint }; } } } diff --git a/clients/openclaw/test/plugin.test.ts b/clients/openclaw/test/plugin.test.ts index 5ff4dec..a24233a 100644 --- a/clients/openclaw/test/plugin.test.ts +++ b/clients/openclaw/test/plugin.test.ts @@ -44,6 +44,30 @@ describe("validateConfig", () => { const result = validateConfig({ context_types: ["event", 42, "observation"] }); expect(result.context_types).toEqual(["event", "observation"]); }); + + it("rejects NaN and negative context_limit", () => { + expect(validateConfig({ context_limit: NaN })).toEqual({}); + expect(validateConfig({ context_limit: -1 })).toEqual({}); + expect(validateConfig({ context_limit: 0 })).toEqual({}); + expect(validateConfig({ context_limit: 3.5 })).toEqual({}); + }); + + it("rejects out-of-range min_salience", () => { + expect(validateConfig({ min_salience: -0.1 })).toEqual({}); + expect(validateConfig({ min_salience: 1.5 })).toEqual({}); + expect(validateConfig({ min_salience: NaN })).toEqual({}); + }); + + it("accepts valid min_salience", () => { + expect(validateConfig({ min_salience: 0 })).toEqual({ min_salience: 0 }); + expect(validateConfig({ min_salience: 0.5 })).toEqual({ min_salience: 0.5 }); + expect(validateConfig({ min_salience: 1 })).toEqual({ min_salience: 1 }); + }); + + it("drops empty context_types array to preserve defaults", () => { + const result = validateConfig({ context_types: [42, true] }); + expect(result.context_types).toBeUndefined(); + }); }); describe("OpenClawMembranePlugin", () => { diff --git a/cmd/membraned/main.go b/cmd/membraned/main.go index 8e00e53..14b4d9b 100644 --- a/cmd/membraned/main.go +++ b/cmd/membraned/main.go @@ -3,6 +3,7 @@ package main import ( "context" "flag" + "fmt" "log" "os" "os/signal" @@ -12,12 +13,20 @@ import ( "github.com/GustyCube/membrane/pkg/membrane" ) +var version = "dev" + func main() { configPath := flag.String("config", "", "path to YAML config file") dbPath := flag.String("db", "", "SQLite database path (overrides config)") addr := flag.String("addr", "", "gRPC listen address (overrides config)") + showVersion := flag.Bool("version", false, "print version and exit") flag.Parse() + if *showVersion { + fmt.Printf("membraned %s\n", version) + return + } + // Load configuration. var cfg *membrane.Config if *configPath != "" { From fbb7e7975429423a0027ec6d9c0709fe69a54bd4 Mon Sep 17 00:00:00 2001 From: Claudia Date: Thu, 12 Mar 2026 12:25:10 +0100 Subject: [PATCH 05/10] fix: address second-round CodeRabbit review findings - Add id and configSchema to openclaw.plugin.json (OpenClaw discovery) - Fix DEFAULT_CONFIG to use valid Membrane MemoryTypes (episodic/semantic/competence) instead of invalid types (event/tool_output/observation) - Filter context_types against VALID_MEMORY_TYPES in validateConfig and search() - Extract episodic timeline summaries in context injection (payload.timeline[].summary) - Update README config path to OpenClaw plugins.entries format - Add regression tests for MemoryType validation (28/28 pass) --- clients/openclaw/README.md | 27 +++++++++++++++------------ clients/openclaw/openclaw.plugin.json | 17 +++++++++++++++++ clients/openclaw/src/index.ts | 25 ++++++++++++++++++++----- clients/openclaw/src/types.ts | 5 ++++- clients/openclaw/test/plugin.test.ts | 16 +++++++++++++--- 5 files changed, 69 insertions(+), 21 deletions(-) diff --git a/clients/openclaw/README.md b/clients/openclaw/README.md index 8e648b1..0229835 100644 --- a/clients/openclaw/README.md +++ b/clients/openclaw/README.md @@ -29,17 +29,20 @@ npx brainplex init # Auto-detects and configures all plugins ## Configuration -`~/.openclaw/plugins/openclaw-membrane/config.json`: - -```json -{ - "grpc_endpoint": "localhost:4222", - "default_sensitivity": "low", - "auto_context": true, - "context_limit": 5, - "min_salience": 0.3, - "context_types": ["event", "tool_output", "observation"] -} +In your OpenClaw config (`openclaw.yaml`), under `plugins.entries`: + +```yaml +plugins: + entries: + openclaw-membrane: + enabled: true + config: + grpc_endpoint: "localhost:4222" + default_sensitivity: "low" + auto_context: true + context_limit: 5 + min_salience: 0.3 + context_types: ["episodic", "semantic", "competence"] ``` | Option | Default | Description | @@ -49,7 +52,7 @@ npx brainplex init # Auto-detects and configures all plugins | `auto_context` | `true` | Auto-inject memories before each agent turn | | `context_limit` | `5` | Max memories to inject | | `min_salience` | `0.3` | Minimum salience score for retrieval | -| `context_types` | `["event", "tool_output", "observation"]` | Memory types to include | +| `context_types` | `["episodic", "semantic", "competence"]` | Memory types: `episodic`, `working`, `semantic`, `competence`, `plan_graph` | ## Usage diff --git a/clients/openclaw/openclaw.plugin.json b/clients/openclaw/openclaw.plugin.json index 071de54..eb4782f 100644 --- a/clients/openclaw/openclaw.plugin.json +++ b/clients/openclaw/openclaw.plugin.json @@ -1,7 +1,24 @@ { + "id": "openclaw-membrane", "name": "openclaw-membrane", "version": "0.4.0", "description": "Membrane episodic memory bridge — ingest events, search memories, auto-inject context", + "configSchema": { + "type": "object", + "properties": { + "grpc_endpoint": { "type": "string", "default": "localhost:4222" }, + "default_sensitivity": { "type": "string", "enum": ["public", "low", "medium", "high", "hyper"], "default": "low" }, + "auto_context": { "type": "boolean", "default": true }, + "context_limit": { "type": "integer", "minimum": 1, "default": 5 }, + "min_salience": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.3 }, + "context_types": { + "type": "array", + "items": { "type": "string", "enum": ["episodic", "working", "semantic", "competence", "plan_graph"] }, + "default": ["episodic", "semantic", "competence"] + } + }, + "additionalProperties": false + }, "hooks": [ "after_agent_reply", "after_tool_call", diff --git a/clients/openclaw/src/index.ts b/clients/openclaw/src/index.ts index f46af50..33313fa 100644 --- a/clients/openclaw/src/index.ts +++ b/clients/openclaw/src/index.ts @@ -11,7 +11,7 @@ import { MembraneClient, type MemoryRecord, type MemoryType, type RetrieveOptions } from "@gustycube/membrane"; import { mapSensitivity, mapEventKind, summarize, buildTags } from "./mapping.js"; import type { PluginConfig, PluginApi, PluginLogger, OpenClawEvent } from "./types.js"; -import { DEFAULT_CONFIG } from "./types.js"; +import { DEFAULT_CONFIG, VALID_MEMORY_TYPES } from "./types.js"; // ── Config ── @@ -32,7 +32,9 @@ export function validateConfig(raw: Record | undefined): Partia result.min_salience = raw.min_salience; } if (Array.isArray(raw.context_types)) { - const filtered = raw.context_types.filter((t): t is string => typeof t === "string"); + const filtered = raw.context_types.filter( + (t): t is string => typeof t === "string" && (VALID_MEMORY_TYPES as readonly string[]).includes(t), + ); if (filtered.length > 0) result.context_types = filtered; } return result; @@ -140,7 +142,12 @@ export class OpenClawMembranePlugin { minSalience: effectiveMinSalience, }; if (effectiveMemoryTypes) { - retrieveOpts.memoryTypes = effectiveMemoryTypes as MemoryType[]; + const validTypes = effectiveMemoryTypes.filter( + (t) => (VALID_MEMORY_TYPES as readonly string[]).includes(t), + ); + if (validTypes.length > 0) { + retrieveOpts.memoryTypes = validTypes as MemoryType[]; + } } return await this.client.retrieve(query, retrieveOpts); } catch (err) { @@ -165,7 +172,15 @@ export class OpenClawMembranePlugin { const lines = records.map((r: MemoryRecord, i: number) => { // Extract human-readable summary from payload when available const payload = r.payload as Record | undefined; - const summary = payload?.summary ?? payload?.content ?? r.id; + // Episodic records store summaries in payload.timeline[].summary + let summary: unknown = undefined; + if (Array.isArray(payload?.timeline)) { + const entry = (payload.timeline as Array>).find( + (e) => typeof e?.summary === "string" && e.summary.length > 0, + ); + if (entry) summary = entry.summary; + } + summary = summary ?? payload?.summary ?? payload?.content ?? r.id; return `${i + 1}. [${r.type}] ${String(summary)}`; }); return `Episodic memory from Membrane:\n${lines.join("\n")}`; @@ -193,5 +208,5 @@ export class OpenClawMembranePlugin { // Re-exports export type { PluginConfig, PluginApi, PluginLogger, OpenClawEvent } from "./types.js"; -export { DEFAULT_CONFIG } from "./types.js"; +export { DEFAULT_CONFIG, VALID_MEMORY_TYPES } from "./types.js"; export { mapSensitivity, mapEventKind, summarize, buildTags } from "./mapping.js"; diff --git a/clients/openclaw/src/types.ts b/clients/openclaw/src/types.ts index 63426ee..023d0f4 100644 --- a/clients/openclaw/src/types.ts +++ b/clients/openclaw/src/types.ts @@ -17,13 +17,16 @@ export interface PluginConfig { context_types: string[]; } +/** Valid Membrane memory types for retrieval filtering */ +export const VALID_MEMORY_TYPES = ["episodic", "working", "semantic", "competence", "plan_graph"] as const; + export const DEFAULT_CONFIG: PluginConfig = { grpc_endpoint: "localhost:4222", default_sensitivity: "low", auto_context: true, context_limit: 5, min_salience: 0.3, - context_types: ["event", "tool_output", "observation"], + context_types: ["episodic", "semantic", "competence"], }; /** OpenClaw hook event passed to plugin hooks */ diff --git a/clients/openclaw/test/plugin.test.ts b/clients/openclaw/test/plugin.test.ts index a24233a..c7b6c83 100644 --- a/clients/openclaw/test/plugin.test.ts +++ b/clients/openclaw/test/plugin.test.ts @@ -40,9 +40,9 @@ describe("validateConfig", () => { expect(validateConfig(undefined)).toEqual({}); }); - it("filters context_types to strings only", () => { - const result = validateConfig({ context_types: ["event", 42, "observation"] }); - expect(result.context_types).toEqual(["event", "observation"]); + it("filters context_types to valid string types only", () => { + const result = validateConfig({ context_types: ["episodic", 42, "working"] }); + expect(result.context_types).toEqual(["episodic", "working"]); }); it("rejects NaN and negative context_limit", () => { @@ -68,6 +68,16 @@ describe("validateConfig", () => { const result = validateConfig({ context_types: [42, true] }); expect(result.context_types).toBeUndefined(); }); + + it("filters context_types to valid Membrane memory types only", () => { + const result = validateConfig({ context_types: ["episodic", "unsupported", "semantic", "event"] }); + expect(result.context_types).toEqual(["episodic", "semantic"]); + }); + + it("drops context_types when none are valid Membrane types", () => { + const result = validateConfig({ context_types: ["event", "tool_output", "observation"] }); + expect(result.context_types).toBeUndefined(); + }); }); describe("OpenClawMembranePlugin", () => { From c3604c9dc314c3b94a4ffe01b30f8c3dd4fe97ac Mon Sep 17 00:00:00 2001 From: Claudia Date: Thu, 12 Mar 2026 13:07:32 +0100 Subject: [PATCH 06/10] fix: correct memory_types schema and reject invalid search filters - Update memory_types in plugin.json with items enum (episodic/working/semantic/competence/plan_graph) - Return early with warning when all provided memoryTypes are invalid in search() --- clients/openclaw/openclaw.plugin.json | 2 +- clients/openclaw/src/index.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/clients/openclaw/openclaw.plugin.json b/clients/openclaw/openclaw.plugin.json index eb4782f..964deb2 100644 --- a/clients/openclaw/openclaw.plugin.json +++ b/clients/openclaw/openclaw.plugin.json @@ -33,7 +33,7 @@ "properties": { "query": { "type": "string", "description": "Natural language query to search memories" }, "limit": { "type": "number", "description": "Maximum results to return (default: 5)" }, - "memory_types": { "type": "array", "description": "Filter by memory type: event, tool_output, observation, working_state" }, + "memory_types": { "type": "array", "items": { "type": "string", "enum": ["episodic", "working", "semantic", "competence", "plan_graph"] }, "description": "Filter by memory type: episodic, working, semantic, competence, plan_graph" }, "min_salience": { "type": "number", "description": "Minimum salience score (0-1, default: 0.3)" } }, "required": ["query"] diff --git a/clients/openclaw/src/index.ts b/clients/openclaw/src/index.ts index 33313fa..666e1f9 100644 --- a/clients/openclaw/src/index.ts +++ b/clients/openclaw/src/index.ts @@ -145,9 +145,11 @@ export class OpenClawMembranePlugin { const validTypes = effectiveMemoryTypes.filter( (t) => (VALID_MEMORY_TYPES as readonly string[]).includes(t), ); - if (validTypes.length > 0) { - retrieveOpts.memoryTypes = validTypes as MemoryType[]; + if (validTypes.length === 0) { + this.log.warn(`[membrane] All provided memoryTypes are invalid: ${effectiveMemoryTypes.join(", ")}`); + return []; } + retrieveOpts.memoryTypes = validTypes as MemoryType[]; } return await this.client.retrieve(query, retrieveOpts); } catch (err) { From 6d23ac39ccbd7da76af75a55e2d2476d1d864ce5 Mon Sep 17 00:00:00 2001 From: Claudia Date: Thu, 12 Mar 2026 13:35:43 +0100 Subject: [PATCH 07/10] fix: use integer type for limit parameter in plugin schema --- clients/openclaw/openclaw.plugin.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/openclaw/openclaw.plugin.json b/clients/openclaw/openclaw.plugin.json index 964deb2..23e492a 100644 --- a/clients/openclaw/openclaw.plugin.json +++ b/clients/openclaw/openclaw.plugin.json @@ -32,7 +32,7 @@ "type": "object", "properties": { "query": { "type": "string", "description": "Natural language query to search memories" }, - "limit": { "type": "number", "description": "Maximum results to return (default: 5)" }, + "limit": { "type": "integer", "description": "Maximum results to return (default: 5)" }, "memory_types": { "type": "array", "items": { "type": "string", "enum": ["episodic", "working", "semantic", "competence", "plan_graph"] }, "description": "Filter by memory type: episodic, working, semantic, competence, plan_graph" }, "min_salience": { "type": "number", "description": "Minimum salience score (0-1, default: 0.3)" } }, From 96639fc673bb2a0c321c390a85dbcfa2cf16796e Mon Sep 17 00:00:00 2001 From: Claudia Date: Thu, 12 Mar 2026 14:21:31 +0100 Subject: [PATCH 08/10] fix: add min/max bounds to limit and min_salience in tool schema --- clients/openclaw/openclaw.plugin.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/clients/openclaw/openclaw.plugin.json b/clients/openclaw/openclaw.plugin.json index 23e492a..dfbef15 100644 --- a/clients/openclaw/openclaw.plugin.json +++ b/clients/openclaw/openclaw.plugin.json @@ -32,9 +32,9 @@ "type": "object", "properties": { "query": { "type": "string", "description": "Natural language query to search memories" }, - "limit": { "type": "integer", "description": "Maximum results to return (default: 5)" }, + "limit": { "type": "integer", "minimum": 1, "description": "Maximum results to return (default: 5)" }, "memory_types": { "type": "array", "items": { "type": "string", "enum": ["episodic", "working", "semantic", "competence", "plan_graph"] }, "description": "Filter by memory type: episodic, working, semantic, competence, plan_graph" }, - "min_salience": { "type": "number", "description": "Minimum salience score (0-1, default: 0.3)" } + "min_salience": { "type": "number", "minimum": 0, "maximum": 1, "description": "Minimum salience score (0-1, default: 0.3)" } }, "required": ["query"] } From 5fa27f654878dbba714a8f9dc30f6dd0d18d72ab Mon Sep 17 00:00:00 2001 From: Claudia Date: Thu, 12 Mar 2026 14:29:40 +0100 Subject: [PATCH 09/10] fix: add kind field for OpenClaw memory plugin slot --- clients/openclaw/openclaw.plugin.json | 1 + 1 file changed, 1 insertion(+) diff --git a/clients/openclaw/openclaw.plugin.json b/clients/openclaw/openclaw.plugin.json index dfbef15..1bdaf3f 100644 --- a/clients/openclaw/openclaw.plugin.json +++ b/clients/openclaw/openclaw.plugin.json @@ -1,5 +1,6 @@ { "id": "openclaw-membrane", + "kind": "memory", "name": "openclaw-membrane", "version": "0.4.0", "description": "Membrane episodic memory bridge — ingest events, search memories, auto-inject context", From 253641423ba123adfd8cd56433b8218c87288f8e Mon Sep 17 00:00:00 2001 From: Claudia Date: Thu, 12 Mar 2026 14:38:01 +0100 Subject: [PATCH 10/10] fix: seal tool parameters against unknown properties --- clients/openclaw/openclaw.plugin.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/clients/openclaw/openclaw.plugin.json b/clients/openclaw/openclaw.plugin.json index 1bdaf3f..4f62b54 100644 --- a/clients/openclaw/openclaw.plugin.json +++ b/clients/openclaw/openclaw.plugin.json @@ -37,7 +37,8 @@ "memory_types": { "type": "array", "items": { "type": "string", "enum": ["episodic", "working", "semantic", "competence", "plan_graph"] }, "description": "Filter by memory type: episodic, working, semantic, competence, plan_graph" }, "min_salience": { "type": "number", "minimum": 0, "maximum": 1, "description": "Minimum salience score (0-1, default: 0.3)" } }, - "required": ["query"] + "required": ["query"], + "additionalProperties": false } } ],