neuralmemory 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,322 @@
1
+ /**
2
+ * NeuralMemory MCP Client — JSON-RPC 2.0 over stdio.
3
+ *
4
+ * Spawns `python -m neural_memory.mcp` and communicates using the
5
+ * MCP protocol (newline-delimited JSON Lines).
6
+ *
7
+ * Zero external dependencies — implements the protocol directly.
8
+ */
9
+
10
+ import { spawn, type ChildProcess } from "node:child_process";
11
+ import type { PluginLogger } from "./types.js";
12
+
13
+ // ── Types ──────────────────────────────────────────────────
14
+
15
+ type PendingRequest = {
16
+ readonly resolve: (value: unknown) => void;
17
+ readonly reject: (error: Error) => void;
18
+ readonly timer: ReturnType<typeof setTimeout>;
19
+ };
20
+
21
+ type JsonRpcMessage = {
22
+ jsonrpc: "2.0";
23
+ id?: number;
24
+ method?: string;
25
+ result?: unknown;
26
+ error?: { code: number; message: string; data?: unknown };
27
+ };
28
+
29
+ export type McpClientOptions = {
30
+ readonly pythonPath: string;
31
+ readonly brain: string;
32
+ readonly logger: PluginLogger;
33
+ readonly timeout?: number;
34
+ readonly initTimeout?: number;
35
+ };
36
+
37
+ // ── Constants ──────────────────────────────────────────────
38
+
39
+ const PROTOCOL_VERSION = "2024-11-05";
40
+ const DEFAULT_TIMEOUT = 30_000;
41
+ const CLIENT_NAME = "openclaw-neuralmemory";
42
+ const CLIENT_VERSION = "1.5.0";
43
+ const MAX_BUFFER_BYTES = 10 * 1024 * 1024; // 10 MB safety cap
44
+ const MAX_STDERR_LINES = 50;
45
+
46
+ /** Env vars forwarded to the MCP child process (least-privilege). */
47
+ export const ALLOWED_ENV_KEYS: ReadonlySet<string> = new Set([
48
+ "PATH",
49
+ "PATHEXT",
50
+ "HOME",
51
+ "USERPROFILE",
52
+ "SYSTEMROOT",
53
+ "TEMP",
54
+ "TMP",
55
+ "LANG",
56
+ "LC_ALL",
57
+ "VIRTUAL_ENV",
58
+ "CONDA_PREFIX",
59
+ "PYTHONPATH",
60
+ "PYTHONHOME",
61
+ "NEURALMEMORY_DIR",
62
+ "NEURALMEMORY_BRAIN",
63
+ "NEURAL_MEMORY_DIR",
64
+ "NEURAL_MEMORY_JSON",
65
+ "NEURAL_MEMORY_DEBUG",
66
+ ]);
67
+
68
+ // ── Client ─────────────────────────────────────────────────
69
+
70
+ export class NeuralMemoryMcpClient {
71
+ private proc: ChildProcess | null = null;
72
+ private requestId = 0;
73
+ private readonly pending = new Map<number, PendingRequest>();
74
+ private rawBuffer: Buffer = Buffer.alloc(0);
75
+ private readonly pythonPath: string;
76
+ private readonly brain: string;
77
+ private readonly logger: PluginLogger;
78
+ private readonly timeout: number;
79
+ private readonly initTimeout: number;
80
+ private _connected = false;
81
+
82
+ constructor(options: McpClientOptions) {
83
+ this.pythonPath = options.pythonPath;
84
+ this.brain = options.brain;
85
+ this.logger = options.logger;
86
+ this.timeout = options.timeout ?? DEFAULT_TIMEOUT;
87
+ this.initTimeout = options.initTimeout ?? 90_000;
88
+ }
89
+
90
+ get connected(): boolean {
91
+ return this._connected;
92
+ }
93
+
94
+ async connect(): Promise<void> {
95
+ const env = buildChildEnv(this.brain);
96
+
97
+ this.proc = spawn(this.pythonPath, ["-m", "neural_memory.mcp"], {
98
+ stdio: ["pipe", "pipe", "pipe"],
99
+ env,
100
+ });
101
+
102
+ this.proc.stdout!.on("data", (chunk: Buffer) => {
103
+ this.rawBuffer = Buffer.concat([this.rawBuffer, chunk]);
104
+ if (this.rawBuffer.length > MAX_BUFFER_BYTES) {
105
+ this.logger.error(
106
+ `MCP buffer exceeded ${MAX_BUFFER_BYTES} bytes — killing process`,
107
+ );
108
+ this.proc?.kill("SIGKILL");
109
+ return;
110
+ }
111
+ this.drainBuffer();
112
+ });
113
+
114
+ const stderrChunks: string[] = [];
115
+
116
+ this.proc.stderr!.on("data", (chunk: Buffer) => {
117
+ const msg = chunk.toString("utf-8").trim();
118
+ if (msg) {
119
+ if (stderrChunks.length < MAX_STDERR_LINES) {
120
+ stderrChunks.push(msg);
121
+ }
122
+ this.logger.warn(`[mcp stderr] ${msg}`);
123
+ }
124
+ });
125
+
126
+ this.proc.on("exit", (code) => {
127
+ this._connected = false;
128
+ const hint =
129
+ code === 1
130
+ ? " — check that neural-memory is installed: pip install neural-memory"
131
+ : "";
132
+ this.rejectAll(new Error(`MCP process exited with code ${code}${hint}`));
133
+ this.logger.error(`MCP process exited (code: ${code})${hint}`);
134
+ });
135
+
136
+ this.proc.on("error", (err) => {
137
+ this._connected = false;
138
+ const hint =
139
+ err.message.includes("ENOENT")
140
+ ? ` — "${this.pythonPath}" not found. Check pythonPath in plugin config.`
141
+ : "";
142
+ this.rejectAll(new Error(`MCP process error: ${err.message}${hint}`));
143
+ this.logger.error(`MCP process error: ${err.message}${hint}`);
144
+ });
145
+
146
+ // MCP initialize handshake (uses longer timeout for cold starts)
147
+ try {
148
+ await this.send("initialize", {
149
+ protocolVersion: PROTOCOL_VERSION,
150
+ capabilities: {},
151
+ clientInfo: { name: CLIENT_NAME, version: CLIENT_VERSION },
152
+ }, this.initTimeout);
153
+ } catch (err) {
154
+ const stderr = stderrChunks.join("\n");
155
+ const detail = stderr
156
+ ? `\nPython stderr:\n${stderr}`
157
+ : "\nNo stderr output — the Python process may have hung.";
158
+ throw new Error(
159
+ `MCP initialize failed: ${(err as Error).message}${detail}\n` +
160
+ `Verify: ${this.pythonPath} -m neural_memory.mcp`,
161
+ );
162
+ }
163
+
164
+ // Send initialized notification (no response expected)
165
+ this.notify("notifications/initialized", {});
166
+
167
+ this._connected = true;
168
+ this.logger.info(
169
+ `MCP connected (brain: ${this.brain}, protocol: ${PROTOCOL_VERSION})`,
170
+ );
171
+ }
172
+
173
+ async callTool(
174
+ name: string,
175
+ args: Record<string, unknown> = {},
176
+ ): Promise<string> {
177
+ const result = (await this.send("tools/call", {
178
+ name,
179
+ arguments: args,
180
+ })) as { content?: Array<{ type: string; text: string }>; isError?: boolean };
181
+
182
+ if (result.isError) {
183
+ const text = result.content?.[0]?.text ?? "Unknown MCP error";
184
+ throw new Error(text);
185
+ }
186
+
187
+ return result.content?.[0]?.text ?? "";
188
+ }
189
+
190
+ async close(): Promise<void> {
191
+ this._connected = false;
192
+ this.rejectAll(new Error("Client closing"));
193
+
194
+ const proc = this.proc;
195
+ this.proc = null;
196
+ this.rawBuffer = Buffer.alloc(0);
197
+
198
+ if (proc) {
199
+ proc.removeAllListeners();
200
+ proc.stdout?.removeAllListeners();
201
+ proc.stderr?.removeAllListeners();
202
+
203
+ const exited = new Promise<void>((resolve) => {
204
+ proc.once("exit", () => resolve());
205
+ setTimeout(() => {
206
+ proc.kill("SIGKILL");
207
+ resolve();
208
+ }, 3_000);
209
+ });
210
+
211
+ proc.kill("SIGTERM");
212
+ await exited;
213
+ }
214
+
215
+ this.logger.info("MCP client closed");
216
+ }
217
+
218
+ // ── JSON-RPC protocol layer ──────────────────────────────
219
+
220
+ private send(method: string, params: unknown, timeoutOverride?: number): Promise<unknown> {
221
+ return new Promise((resolve, reject) => {
222
+ if (!this.proc?.stdin?.writable) {
223
+ reject(new Error("MCP process not available"));
224
+ return;
225
+ }
226
+
227
+ const id = ++this.requestId;
228
+ const ms = timeoutOverride ?? this.timeout;
229
+ const timer = setTimeout(() => {
230
+ this.pending.delete(id);
231
+ reject(new Error(`MCP timeout: ${method} (${ms}ms)`));
232
+ }, ms);
233
+
234
+ this.pending.set(id, { resolve, reject, timer });
235
+ this.writeMessage({ jsonrpc: "2.0", id, method, params });
236
+ });
237
+ }
238
+
239
+ private notify(method: string, params: unknown): void {
240
+ if (!this.proc?.stdin?.writable) return;
241
+ this.writeMessage({ jsonrpc: "2.0", method, params });
242
+ }
243
+
244
+ private writeMessage(message: object): void {
245
+ if (!this.proc?.stdin?.writable) return;
246
+ const json = JSON.stringify(message);
247
+ const frame = `${json}\n`;
248
+ this.proc.stdin.write(frame);
249
+ }
250
+
251
+ // ── Response parsing (newline-delimited JSON Lines) ────────
252
+
253
+ private drainBuffer(): void {
254
+ while (true) {
255
+ const newlineIndex = this.rawBuffer.indexOf("\n");
256
+ if (newlineIndex === -1) break;
257
+
258
+ const line = this.rawBuffer.subarray(0, newlineIndex).toString("utf-8");
259
+ this.rawBuffer = this.rawBuffer.subarray(newlineIndex + 1);
260
+
261
+ if (!line.trim()) continue;
262
+
263
+ try {
264
+ const message = JSON.parse(line) as JsonRpcMessage;
265
+ this.handleMessage(message);
266
+ } catch (err) {
267
+ this.logger.error(
268
+ `Failed to parse MCP message: ${(err as Error).message}`,
269
+ );
270
+ }
271
+ }
272
+ }
273
+
274
+ private handleMessage(message: JsonRpcMessage): void {
275
+ // Notifications (no id) — ignore silently
276
+ if (message.id == null) return;
277
+
278
+ const pending = this.pending.get(message.id);
279
+ if (!pending) return;
280
+
281
+ this.pending.delete(message.id);
282
+ clearTimeout(pending.timer);
283
+
284
+ if (message.error) {
285
+ pending.reject(
286
+ new Error(
287
+ `MCP error ${message.error.code}: ${message.error.message}`,
288
+ ),
289
+ );
290
+ } else {
291
+ pending.resolve(message.result);
292
+ }
293
+ }
294
+
295
+ private rejectAll(error: Error): void {
296
+ for (const [, pending] of this.pending) {
297
+ clearTimeout(pending.timer);
298
+ pending.reject(error);
299
+ }
300
+ this.pending.clear();
301
+ }
302
+ }
303
+
304
+ // ── Helpers ─────────────────────────────────────────────────
305
+
306
+ /** Build a minimal env for the child process (least-privilege). */
307
+ export function buildChildEnv(brain: string): Record<string, string> {
308
+ const env: Record<string, string> = {};
309
+
310
+ for (const key of ALLOWED_ENV_KEYS) {
311
+ const value = process.env[key];
312
+ if (value !== undefined) {
313
+ env[key] = value;
314
+ }
315
+ }
316
+
317
+ if (brain !== "default") {
318
+ env.NEURALMEMORY_BRAIN = brain;
319
+ }
320
+
321
+ return env;
322
+ }
package/src/tools.ts ADDED
@@ -0,0 +1,218 @@
1
+ /**
2
+ * NeuralMemory tool definitions for OpenClaw.
3
+ *
4
+ * Each tool proxies to the MCP server via JSON-RPC.
5
+ *
6
+ * Uses raw JSON Schema for parameters. Provider compatibility notes:
7
+ * - `additionalProperties: false` required by OpenAI strict mode
8
+ * - `number` instead of `integer` for Gemini compatibility
9
+ * - No `maxLength`/`maxItems`/`minimum`/`maximum` — some providers
10
+ * reject schemas with constraint keywords; our MCP server validates
11
+ *
12
+ * Registers 6 core tools:
13
+ * nmem_remember — Store a memory
14
+ * nmem_recall — Query/search memories
15
+ * nmem_context — Get recent context
16
+ * nmem_todo — Quick TODO shortcut
17
+ * nmem_stats — Brain statistics
18
+ * nmem_health — Brain health diagnostics
19
+ */
20
+
21
+ import type { NeuralMemoryMcpClient } from "./mcp-client.js";
22
+
23
+ // ── Types ──────────────────────────────────────────────────
24
+
25
+ type JsonSchema = {
26
+ readonly type: "object";
27
+ readonly properties: Record<string, unknown>;
28
+ readonly required?: readonly string[];
29
+ readonly additionalProperties?: boolean;
30
+ };
31
+
32
+ export type ToolDefinition = {
33
+ readonly name: string;
34
+ readonly description: string;
35
+ readonly parameters: JsonSchema;
36
+ readonly execute: (id: string, args: Record<string, unknown>) => Promise<unknown>;
37
+ };
38
+
39
+ // ── Tool factory ───────────────────────────────────────────
40
+
41
+ export function createTools(mcp: NeuralMemoryMcpClient): ToolDefinition[] {
42
+ const call = async (
43
+ toolName: string,
44
+ args: Record<string, unknown>,
45
+ ): Promise<unknown> => {
46
+ if (!mcp.connected) {
47
+ return {
48
+ error: true,
49
+ message: "NeuralMemory service not running. Start the service first.",
50
+ };
51
+ }
52
+
53
+ try {
54
+ const raw = await mcp.callTool(toolName, args);
55
+ try {
56
+ return JSON.parse(raw);
57
+ } catch {
58
+ return { text: raw };
59
+ }
60
+ } catch (err) {
61
+ return {
62
+ error: true,
63
+ message: `Tool ${toolName} failed: ${(err as Error).message}`,
64
+ };
65
+ }
66
+ };
67
+
68
+ return [
69
+ {
70
+ name: "nmem_remember",
71
+ description:
72
+ "Store a memory in NeuralMemory. Use this to remember facts, decisions, " +
73
+ "insights, todos, errors, and other information that should persist across sessions.",
74
+ parameters: {
75
+ type: "object",
76
+ properties: {
77
+ content: {
78
+ type: "string",
79
+ description: "The content to remember",
80
+ },
81
+ type: {
82
+ type: "string",
83
+ enum: [
84
+ "fact",
85
+ "decision",
86
+ "preference",
87
+ "todo",
88
+ "insight",
89
+ "context",
90
+ "instruction",
91
+ "error",
92
+ "workflow",
93
+ "reference",
94
+ ],
95
+ description: "Memory type (auto-detected if not specified)",
96
+ },
97
+ priority: {
98
+ type: "number",
99
+ description: "Priority 0-10 (5=normal, 10=critical)",
100
+ },
101
+ tags: {
102
+ type: "array",
103
+ items: { type: "string" },
104
+ description: "Tags for categorization",
105
+ },
106
+ expires_days: {
107
+ type: "number",
108
+ description: "Days until memory expires (1-3650)",
109
+ },
110
+ },
111
+ required: ["content"],
112
+ additionalProperties: false,
113
+ },
114
+ execute: (_id, args) => call("nmem_remember", args),
115
+ },
116
+
117
+ {
118
+ name: "nmem_recall",
119
+ description:
120
+ "Query memories from NeuralMemory. Use this to recall past information, " +
121
+ "decisions, patterns, or context relevant to the current task.",
122
+ parameters: {
123
+ type: "object",
124
+ properties: {
125
+ query: {
126
+ type: "string",
127
+ description: "The query to search memories",
128
+ },
129
+ depth: {
130
+ type: "number",
131
+ description:
132
+ "Search depth: 0=instant, 1=context, 2=habit, 3=deep",
133
+ },
134
+ max_tokens: {
135
+ type: "number",
136
+ description: "Maximum tokens in response (default: 500)",
137
+ },
138
+ min_confidence: {
139
+ type: "number",
140
+ description: "Minimum confidence threshold (0-1)",
141
+ },
142
+ },
143
+ required: ["query"],
144
+ additionalProperties: false,
145
+ },
146
+ execute: (_id, args) => call("nmem_recall", args),
147
+ },
148
+
149
+ {
150
+ name: "nmem_context",
151
+ description:
152
+ "Get recent context from NeuralMemory. Use this at the start of " +
153
+ "tasks to inject relevant recent memories.",
154
+ parameters: {
155
+ type: "object",
156
+ properties: {
157
+ limit: {
158
+ type: "number",
159
+ description: "Number of recent memories (default: 10, max: 200)",
160
+ },
161
+ fresh_only: {
162
+ type: "boolean",
163
+ description: "Only include memories less than 30 days old",
164
+ },
165
+ },
166
+ additionalProperties: false,
167
+ },
168
+ execute: (_id, args) => call("nmem_context", args),
169
+ },
170
+
171
+ {
172
+ name: "nmem_todo",
173
+ description:
174
+ "Quick shortcut to add a TODO memory with 30-day expiry.",
175
+ parameters: {
176
+ type: "object",
177
+ properties: {
178
+ task: {
179
+ type: "string",
180
+ description: "The task to remember",
181
+ },
182
+ priority: {
183
+ type: "number",
184
+ description: "Priority 0-10 (default: 5)",
185
+ },
186
+ },
187
+ required: ["task"],
188
+ additionalProperties: false,
189
+ },
190
+ execute: (_id, args) => call("nmem_todo", args),
191
+ },
192
+
193
+ {
194
+ name: "nmem_stats",
195
+ description:
196
+ "Get brain statistics including memory counts and freshness.",
197
+ parameters: {
198
+ type: "object",
199
+ properties: {},
200
+ additionalProperties: false,
201
+ },
202
+ execute: (_id, args) => call("nmem_stats", args),
203
+ },
204
+
205
+ {
206
+ name: "nmem_health",
207
+ description:
208
+ "Get brain health diagnostics including grade, purity score, " +
209
+ "and recommendations.",
210
+ parameters: {
211
+ type: "object",
212
+ properties: {},
213
+ additionalProperties: false,
214
+ },
215
+ execute: (_id, args) => call("nmem_health", args),
216
+ },
217
+ ];
218
+ }
package/src/types.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Minimal OpenClaw plugin types for standalone compilation.
3
+ * At runtime, jiti resolves the full types from OpenClaw's codebase.
4
+ */
5
+
6
+ export type PluginLogger = {
7
+ debug?: (message: string) => void;
8
+ info: (message: string) => void;
9
+ warn: (message: string) => void;
10
+ error: (message: string) => void;
11
+ };
12
+
13
+ export type PluginKind = "memory";
14
+
15
+ export type OpenClawPluginServiceContext = {
16
+ config: unknown;
17
+ workspaceDir?: string;
18
+ stateDir: string;
19
+ logger: PluginLogger;
20
+ };
21
+
22
+ export type OpenClawPluginService = {
23
+ id: string;
24
+ start: (ctx: OpenClawPluginServiceContext) => void | Promise<void>;
25
+ stop?: (ctx: OpenClawPluginServiceContext) => void | Promise<void>;
26
+ };
27
+
28
+ export type BeforeAgentStartEvent = {
29
+ prompt: string;
30
+ messages?: unknown[];
31
+ };
32
+
33
+ export type BeforeAgentStartResult = {
34
+ systemPrompt?: string; // Appended to system prompt — last handler wins
35
+ prependContext?: string; // Prepended to conversation context — all handlers concatenated
36
+ modelOverride?: string; // Override model for this run — first defined wins
37
+ providerOverride?: string; // Override provider for this run — first defined wins
38
+ };
39
+
40
+ export type AgentEndEvent = {
41
+ messages: unknown[];
42
+ success: boolean;
43
+ error?: string;
44
+ durationMs?: number;
45
+ };
46
+
47
+ export type AgentContext = {
48
+ agentId?: string;
49
+ sessionKey?: string;
50
+ workspaceDir?: string;
51
+ };
52
+
53
+ export type OpenClawPluginApi = {
54
+ id: string;
55
+ name: string;
56
+ config: unknown;
57
+ pluginConfig?: Record<string, unknown>;
58
+ runtime: unknown;
59
+ logger: PluginLogger;
60
+ registerTool: (
61
+ tool: unknown,
62
+ opts?: { name?: string; names?: string[] },
63
+ ) => void;
64
+ registerService: (service: OpenClawPluginService) => void;
65
+ on: (
66
+ hookName: string,
67
+ handler: (...args: unknown[]) => unknown,
68
+ opts?: { priority?: number },
69
+ ) => void;
70
+ };
71
+
72
+ export type OpenClawPluginDefinition = {
73
+ id?: string;
74
+ name?: string;
75
+ description?: string;
76
+ version?: string;
77
+ kind?: PluginKind;
78
+ register?: (api: OpenClawPluginApi) => void | Promise<void>;
79
+ activate?: (api: OpenClawPluginApi) => void | Promise<void>;
80
+ };