@sage-protocol/openclaw-sage 0.1.3 → 0.1.4

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.
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.1.3"
2
+ ".": "0.1.4"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.4](https://github.com/sage-protocol/openclaw-sage/compare/openclaw-sage-v0.1.3...openclaw-sage-v0.1.4) (2026-02-04)
4
+
5
+
6
+ ### Features
7
+
8
+ * add support for external MCP servers from mcp-servers.toml ([d7e6283](https://github.com/sage-protocol/openclaw-sage/commit/d7e62836296fb6032e62b036b8a0900d1d384198))
9
+ * auto-inject context at agent start ([6417254](https://github.com/sage-protocol/openclaw-sage/commit/6417254168f6307d42b54880c07cb46e62832514))
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * adding SOUL.md and rlm test ([2644710](https://github.com/sage-protocol/openclaw-sage/commit/26447107a4f40d675480f9d36b5bb9f058ed8e33))
15
+ * pass HOME and XDG env vars to sage subprocess for auth persistence ([653ff31](https://github.com/sage-protocol/openclaw-sage/commit/653ff3135996c1e82d916794d74fe61510a5a1fd))
16
+
3
17
  ## [0.1.3](https://github.com/sage-protocol/openclaw-sage/compare/openclaw-sage-v0.1.2...openclaw-sage-v0.1.3) (2026-02-02)
4
18
 
5
19
 
package/README.md CHANGED
@@ -25,6 +25,30 @@ The plugin auto-detects the `sage` binary from PATH. To override:
25
25
  }
26
26
  ```
27
27
 
28
+ ### Auto-Inject / Auto-Suggest
29
+
30
+ This plugin uses OpenClaw's plugin hook API to inject context at the start of each agent run (`before_agent_start`).
31
+
32
+ Available config fields:
33
+
34
+ ```json
35
+ {
36
+ "autoInjectContext": true,
37
+ "autoSuggestSkills": true,
38
+ "suggestLimit": 3,
39
+ "minPromptLen": 12,
40
+ "maxPromptBytes": 16384
41
+ }
42
+ ```
43
+
44
+ ### Avoiding Double Injection
45
+
46
+ If you also enabled Sage's OpenClaw *internal hook* (installed by `sage init --openclaw`), both the hook and this plugin can inject Sage context.
47
+
48
+ - Recommended: keep the plugin injection on, and disable the internal hook injection via `SAGE_OPENCLAW_INJECT_CONTEXT=0` in your OpenClaw environment.
49
+
50
+ The internal hook exists mainly for bootstrap-file injection; the plugin is the preferred place for per-run injection and suggestions.
51
+
28
52
  ## What It Provides
29
53
 
30
54
  Once loaded, all Sage MCP tools are available in OpenClaw:
package/SOUL.md CHANGED
@@ -17,7 +17,7 @@ Prompt the user to run `sage init` and/or `sage agent quickstart` when any of th
17
17
 
18
18
  - The user asks about setup, installation, configuration, MCP tools, skills, libraries, or OpenClaw integration.
19
19
  - The session appears to be a first run (no prior mention of `sage init` / `sage agent quickstart`).
20
- - The user hits auth/credits/wallet friction (examples: wallet not configured, faucet needed, IPFS credits missing, library sync failing).
20
+ - The user hits auth/credits/wallet friction (examples: wallet not configured, SXXX tokens needed, IPFS credits missing, library sync failing).
21
21
 
22
22
  ## How To Prompt (Non-Spammy)
23
23
 
@@ -31,13 +31,15 @@ If Sage is not initialized:
31
31
 
32
32
  ```text
33
33
  Run `sage init` to set up the Sage skill + MCP integration for your environment.
34
- After that, run `sage agent quickstart` to claim testnet tokens + IPFS credits.
34
+ Then request SXXX tokens: `sage chat send global:agents "request tokens"`
35
+ Then claim IPFS credits: `sage agent quickstart`
35
36
  ```
36
37
 
37
38
  If Sage is initialized but the user needs tokens/credits:
38
39
 
39
40
  ```text
40
- Run `sage agent quickstart` to claim testnet tokens + IPFS credits (it will also check your current status).
41
+ Request SXXX tokens from the bot: `sage chat send global:agents "request tokens"`
42
+ Then claim IPFS credits: `sage agent quickstart`
41
43
  ```
42
44
 
43
45
  If the user is using OpenClaw and doesn't have MCP tools:
@@ -14,6 +14,26 @@
14
14
  "sageBinary": {
15
15
  "type": "string",
16
16
  "description": "Path to sage binary (default: auto-detect from PATH)"
17
+ },
18
+ "autoInjectContext": {
19
+ "type": "boolean",
20
+ "description": "Inject Sage tool context into the agent at start (default: true)"
21
+ },
22
+ "autoSuggestSkills": {
23
+ "type": "boolean",
24
+ "description": "Suggest relevant skills at agent start (default: true)"
25
+ },
26
+ "suggestLimit": {
27
+ "type": "number",
28
+ "description": "Max number of skill suggestions to include (default: 3)"
29
+ },
30
+ "minPromptLen": {
31
+ "type": "number",
32
+ "description": "Minimum prompt length before suggesting (default: 12)"
33
+ },
34
+ "maxPromptBytes": {
35
+ "type": "number",
36
+ "description": "Max prompt bytes forwarded to suggestion search (default: 16384)"
17
37
  }
18
38
  }
19
39
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sage-protocol/openclaw-sage",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Sage MCP bridge plugin for OpenClaw — prompt libraries, skills, governance, and on-chain operations",
5
5
  "main": "src/index.ts",
6
6
  "type": "module",
@@ -11,14 +11,16 @@
11
11
  },
12
12
  "scripts": {
13
13
  "typecheck": "tsc --noEmit",
14
- "test": "npx --yes tsx src/mcp-bridge.test.ts"
14
+ "test": "node --import tsx src/mcp-bridge.test.ts"
15
15
  },
16
16
  "dependencies": {
17
+ "@iarna/toml": "^2.2.5",
17
18
  "@sinclair/typebox": "^0.34.0"
18
19
  },
19
20
  "devDependencies": {
20
- "typescript": "^5.6.0",
21
- "tsx": "^4.19.0"
21
+ "@types/node": "^25.2.0",
22
+ "tsx": "^4.19.0",
23
+ "typescript": "^5.6.0"
22
24
  },
23
25
  "license": "MIT"
24
26
  }
package/src/index.ts CHANGED
@@ -1,9 +1,37 @@
1
1
  import { Type } from "@sinclair/typebox";
2
-
3
- import { spawn } from "node:child_process";
2
+ import { readFileSync, existsSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import TOML from "@iarna/toml";
4
6
 
5
7
  import { McpBridge, type McpToolDef } from "./mcp-bridge.js";
6
8
 
9
+ const SAGE_CONTEXT = `## Sage MCP Tools Available
10
+
11
+ You have access to Sage MCP tools for prompts, skills, and knowledge discovery.
12
+
13
+ ### Prompt Discovery
14
+ - \`search_prompts\` - Hybrid keyword + semantic search for prompts
15
+ - \`list_prompts\` - Browse prompts by source (local/onchain)
16
+ - \`get_prompt\` - Get full prompt content by key
17
+ - \`builder_recommend\` - AI-powered prompt suggestions based on intent
18
+
19
+ ### Skills
20
+ - \`search_skills\` / \`list_skills\` - Find available skills
21
+ - \`get_skill\` - Get skill details and content
22
+ - \`use_skill\` - Activate a skill (auto-provisions required MCP servers)
23
+
24
+ ### External Tools (via Hub)
25
+ - \`hub_list_servers\` - List available MCP servers (memory, github, brave, etc.)
26
+ - \`hub_start_server\` - Start an MCP server to gain access to its tools
27
+ - \`hub_status\` - Check which servers are currently running
28
+
29
+ ### Best Practices
30
+ 1. **Search before implementing** - Use \`search_prompts\` or \`builder_recommend\` to find existing solutions
31
+ 2. **Use skills for complex tasks** - Skills bundle prompts + MCP servers for specific workflows
32
+ 3. **Start additional servers as needed** - Use \`hub_start_server\` for memory, github, brave search, etc.
33
+ 4. **Check skill requirements** - Skills may require specific MCP servers; \`use_skill\` auto-provisions them`;
34
+
7
35
  /**
8
36
  * Minimal type stubs for OpenClaw plugin API.
9
37
  *
@@ -27,13 +55,104 @@ type PluginApi = {
27
55
  id: string;
28
56
  name: string;
29
57
  logger: PluginLogger;
58
+ pluginConfig?: Record<string, unknown>;
30
59
  registerTool: (tool: unknown, opts?: { name?: string; optional?: boolean }) => void;
31
60
  registerService: (service: {
32
61
  id: string;
33
62
  start: (ctx: PluginServiceContext) => void | Promise<void>;
34
63
  stop?: (ctx: PluginServiceContext) => void | Promise<void>;
35
64
  }) => void;
36
- on: (hook: string, handler: (...args: unknown[]) => void | Promise<void>) => void;
65
+ on: (hook: string, handler: (...args: unknown[]) => unknown | Promise<unknown>) => void;
66
+ };
67
+
68
+ function clampInt(raw: unknown, def: number, min: number, max: number): number {
69
+ const n = typeof raw === "string" && raw.trim() ? Number(raw) : Number(raw);
70
+ if (!Number.isFinite(n)) return def;
71
+ return Math.min(max, Math.max(min, Math.trunc(n)));
72
+ }
73
+
74
+ function truncateUtf8(s: string, maxBytes: number): string {
75
+ if (Buffer.byteLength(s, "utf8") <= maxBytes) return s;
76
+
77
+ let lo = 0;
78
+ let hi = s.length;
79
+ while (lo < hi) {
80
+ const mid = Math.ceil((lo + hi) / 2);
81
+ if (Buffer.byteLength(s.slice(0, mid), "utf8") <= maxBytes) lo = mid;
82
+ else hi = mid - 1;
83
+ }
84
+ return s.slice(0, lo);
85
+ }
86
+
87
+ function normalizePrompt(prompt: string, opts?: { maxBytes?: number }): string {
88
+ const trimmed = prompt.trim();
89
+ if (!trimmed) return "";
90
+ const maxBytes = clampInt(opts?.maxBytes, 16_384, 512, 65_536);
91
+ return truncateUtf8(trimmed, maxBytes);
92
+ }
93
+
94
+ function extractJsonFromMcpResult(result: unknown): unknown {
95
+ const anyResult = result as any;
96
+ if (!anyResult || typeof anyResult !== "object") return undefined;
97
+
98
+ // Sage MCP tools typically return { content: [{ type: 'text', text: '...json...' }], isError?: bool }
99
+ const text =
100
+ Array.isArray(anyResult.content) && anyResult.content.length
101
+ ? anyResult.content
102
+ .map((c: any) => (c && typeof c.text === "string" ? c.text : ""))
103
+ .filter(Boolean)
104
+ .join("\n")
105
+ : undefined;
106
+
107
+ if (!text) return undefined;
108
+ try {
109
+ return JSON.parse(text);
110
+ } catch {
111
+ return undefined;
112
+ }
113
+ }
114
+
115
+ type SkillSearchResult = {
116
+ key?: string;
117
+ name?: string;
118
+ description?: string;
119
+ source?: string;
120
+ library?: string;
121
+ mcpServers?: string[];
122
+ };
123
+
124
+ function formatSkillSuggestions(results: SkillSearchResult[], limit: number): string {
125
+ const items = results
126
+ .filter((r) => r && typeof r.key === "string" && r.key.trim())
127
+ .slice(0, limit);
128
+ if (!items.length) return "";
129
+
130
+ const lines: string[] = [];
131
+ lines.push("## Suggested Skills");
132
+ lines.push("");
133
+ for (const r of items) {
134
+ const key = r.key!.trim();
135
+ const desc = typeof r.description === "string" ? r.description.trim() : "";
136
+ const origin = typeof r.library === "string" && r.library.trim() ? ` (from ${r.library.trim()})` : "";
137
+ const servers = Array.isArray(r.mcpServers) && r.mcpServers.length ? ` — requires: ${r.mcpServers.join(", ")}` : "";
138
+ lines.push(`- \`use_skill\` \`${key}\`${origin}${desc ? `: ${desc}` : ""}${servers}`);
139
+ }
140
+ return lines.join("\n");
141
+ }
142
+
143
+ /** Custom server configuration from mcp-servers.toml */
144
+ type CustomServerConfig = {
145
+ id: string;
146
+ name: string;
147
+ description?: string;
148
+ enabled: boolean;
149
+ source: {
150
+ type: "npx" | "node" | "binary";
151
+ package?: string;
152
+ path?: string;
153
+ };
154
+ extra_args?: string[];
155
+ env?: Record<string, string>;
37
156
  };
38
157
 
39
158
  /**
@@ -98,205 +217,229 @@ function toToolResult(mcpResult: unknown) {
98
217
  };
99
218
  }
100
219
 
101
- // ── Plugin Definition ────────────────────────────────────────────────────────
102
-
103
- let bridge: McpBridge | null = null;
104
-
105
- function extractText(input: unknown): string {
106
- if (typeof input === "string") return input;
107
- if (!input || typeof input !== "object") return "";
108
-
109
- const obj = input as any;
110
- const direct = [obj.text, obj.content, obj.prompt, obj.message, obj.input];
111
- for (const c of direct) {
112
- if (typeof c === "string" && c.trim()) return c.trim();
113
- }
220
+ /**
221
+ * Load custom server configurations from ~/.config/sage/mcp-servers.toml
222
+ */
223
+ function loadCustomServers(): CustomServerConfig[] {
224
+ const configPath = join(homedir(), ".config", "sage", "mcp-servers.toml");
114
225
 
115
- if (obj.message && typeof obj.message === "object") {
116
- const msg = obj.message as any;
117
- if (typeof msg.text === "string" && msg.text.trim()) return msg.text.trim();
118
- if (typeof msg.content === "string" && msg.content.trim()) return msg.content.trim();
226
+ if (!existsSync(configPath)) {
227
+ return [];
119
228
  }
120
229
 
121
- // Transcript-style: [{role, content}]
122
- if (Array.isArray(obj.messages)) {
123
- for (let i = obj.messages.length - 1; i >= 0; i--) {
124
- const m = obj.messages[i];
125
- if (!m || typeof m !== "object") continue;
126
- const mm = m as any;
127
- if (typeof mm.content === "string" && mm.content.trim()) return mm.content.trim();
128
- if (typeof mm.text === "string" && mm.text.trim()) return mm.text.trim();
129
- if (Array.isArray(mm.content)) {
130
- const text = mm.content
131
- .map((b: any) => (b?.type === "text" ? b?.text : ""))
132
- .filter(Boolean)
133
- .join("\n");
134
- if (text.trim()) return text.trim();
135
- }
230
+ try {
231
+ const content = readFileSync(configPath, "utf8");
232
+ const config = TOML.parse(content) as {
233
+ custom?: Record<string, {
234
+ id: string;
235
+ name: string;
236
+ description?: string;
237
+ enabled: boolean;
238
+ source: { type: string; package?: string; path?: string };
239
+ extra_args?: string[];
240
+ env?: Record<string, string>;
241
+ }>;
242
+ };
243
+
244
+ if (!config.custom) {
245
+ return [];
136
246
  }
137
- }
138
-
139
- return "";
140
- }
141
247
 
142
- function lastAssistantText(input: unknown): string {
143
- if (!input || typeof input !== "object") return "";
144
- const obj = input as any;
145
-
146
- const candidates = [obj.final, obj.output, obj.result, obj.response];
147
- for (const c of candidates) {
148
- const t = extractText(c);
149
- if (t) return t;
248
+ return Object.values(config.custom)
249
+ .filter((s) => s.enabled)
250
+ .map((s) => ({
251
+ id: s.id,
252
+ name: s.name,
253
+ description: s.description,
254
+ enabled: s.enabled,
255
+ source: {
256
+ type: s.source.type as "npx" | "node" | "binary",
257
+ package: s.source.package,
258
+ path: s.source.path,
259
+ },
260
+ extra_args: s.extra_args,
261
+ env: s.env,
262
+ }));
263
+ } catch (err) {
264
+ console.error(`Failed to parse mcp-servers.toml: ${err}`);
265
+ return [];
150
266
  }
267
+ }
151
268
 
152
- if (Array.isArray(obj.messages)) {
153
- for (let i = obj.messages.length - 1; i >= 0; i--) {
154
- const m = obj.messages[i];
155
- if (!m || typeof m !== "object") continue;
156
- const mm = m as any;
157
- if (mm.role === "assistant") {
158
- const t = extractText(mm);
159
- if (t) return t;
160
- }
161
- }
269
+ /**
270
+ * Create command and args for spawning an external server
271
+ */
272
+ function getServerCommand(server: CustomServerConfig): { command: string; args: string[] } {
273
+ switch (server.source.type) {
274
+ case "npx":
275
+ return {
276
+ command: "npx",
277
+ args: ["-y", server.source.package!, ...(server.extra_args || [])],
278
+ };
279
+ case "node":
280
+ return {
281
+ command: "node",
282
+ args: [server.source.path!, ...(server.extra_args || [])],
283
+ };
284
+ case "binary":
285
+ return {
286
+ command: server.source.path!,
287
+ args: server.extra_args || [],
288
+ };
289
+ default:
290
+ throw new Error(`Unknown source type: ${server.source.type}`);
162
291
  }
163
-
164
- return "";
165
292
  }
166
293
 
167
- function toStringOrEmpty(v: unknown): string {
168
- if (typeof v === "string") return v;
169
- if (typeof v === "number") return String(v);
170
- if (typeof v === "boolean") return v ? "1" : "0";
171
- return "";
172
- }
294
+ // ── Plugin Definition ────────────────────────────────────────────────────────
173
295
 
174
- function fireAndForget(cmd: string, args: string[], env: Record<string, string>): void {
175
- try {
176
- const p = spawn(cmd, args, {
177
- env: { ...process.env, ...env },
178
- stdio: "ignore",
179
- detached: true,
180
- });
181
- p.unref();
182
- } catch {
183
- // ignore
184
- }
185
- }
296
+ let sageBridge: McpBridge | null = null;
297
+ const externalBridges: Map<string, McpBridge> = new Map();
186
298
 
187
299
  const plugin = {
188
300
  id: "openclaw-sage",
189
301
  name: "Sage Protocol",
190
- version: "0.1.2",
302
+ version: "0.2.0",
191
303
  description:
192
- "Sage MCP tools for prompt libraries, skills, governance, and on-chain operations",
304
+ "Sage MCP tools for prompt libraries, skills, governance, and on-chain operations (including external servers)",
193
305
 
194
306
  register(api: PluginApi) {
195
- bridge = new McpBridge("sage", ["mcp", "start"]);
196
-
197
- bridge.on("log", (line: string) => api.logger.info(`[sage-mcp] ${line}`));
198
- bridge.on("error", (err: Error) => api.logger.error(`[sage-mcp] ${err.message}`));
199
-
200
- // RLM capture hooks (derived data): attach prompt/response pairs with OpenClaw tags.
201
- api.on("message_received", async (evt: unknown) => {
202
- const prompt = extractText(evt);
203
- if (!prompt) return;
204
-
205
- const e = evt as any;
206
- const sessionId =
207
- toStringOrEmpty(e?.sessionId) ||
208
- toStringOrEmpty(e?.sessionKey) ||
209
- toStringOrEmpty(e?.context?.sessionId) ||
210
- toStringOrEmpty(e?.context?.sessionKey);
211
- const workspaceDir = toStringOrEmpty(e?.workspaceDir) || toStringOrEmpty(e?.context?.workspaceDir);
212
-
213
- const attrs = {
214
- openclaw: {
215
- hook: "message_received",
216
- sessionId: toStringOrEmpty(e?.sessionId) || toStringOrEmpty(e?.context?.sessionId),
217
- sessionKey: toStringOrEmpty(e?.sessionKey) || toStringOrEmpty(e?.context?.sessionKey),
218
- channel: toStringOrEmpty(e?.channel) || toStringOrEmpty(e?.context?.commandSource),
219
- senderId: toStringOrEmpty(e?.senderId) || toStringOrEmpty(e?.context?.senderId),
220
- },
221
- };
222
-
223
- fireAndForget("sage", ["capture", "hook", "prompt"], {
224
- SAGE_SOURCE: "openclaw",
225
- OPENCLAW: "1",
226
- PROMPT: prompt,
227
- SAGE_SESSION_ID: sessionId,
228
- SAGE_WORKSPACE: workspaceDir,
229
- SAGE_MODEL: toStringOrEmpty(e?.model) || toStringOrEmpty(e?.context?.model),
230
- SAGE_PROVIDER: toStringOrEmpty(e?.provider) || toStringOrEmpty(e?.context?.provider),
231
- SAGE_CAPTURE_ATTRIBUTES_JSON: JSON.stringify(attrs),
232
- });
233
- });
234
-
235
- api.on("agent_end", async (evt: unknown) => {
236
- const response = lastAssistantText(evt);
237
- if (!response) return;
238
-
239
- const e = evt as any;
240
- const tokensIn =
241
- toStringOrEmpty(e?.usage?.tokens_input) ||
242
- toStringOrEmpty(e?.usage?.input_tokens) ||
243
- toStringOrEmpty(e?.usage?.inputTokens);
244
- const tokensOut =
245
- toStringOrEmpty(e?.usage?.tokens_output) ||
246
- toStringOrEmpty(e?.usage?.output_tokens) ||
247
- toStringOrEmpty(e?.usage?.outputTokens);
248
-
249
- fireAndForget("sage", ["capture", "hook", "response"], {
250
- SAGE_SOURCE: "openclaw",
251
- OPENCLAW: "1",
252
- LAST_RESPONSE: response,
253
- TOKENS_INPUT: tokensIn,
254
- TOKENS_OUTPUT: tokensOut,
255
- });
307
+ const pluginCfg = api.pluginConfig ?? {};
308
+ const sageBinary = typeof pluginCfg.sageBinary === "string" && pluginCfg.sageBinary.trim()
309
+ ? pluginCfg.sageBinary.trim()
310
+ : "sage";
311
+
312
+ const autoInject = pluginCfg.autoInjectContext !== false;
313
+ const autoSuggest = pluginCfg.autoSuggestSkills !== false;
314
+ const suggestLimit = clampInt(pluginCfg.suggestLimit, 3, 1, 10);
315
+ const minPromptLen = clampInt(pluginCfg.minPromptLen, 12, 0, 500);
316
+ const maxPromptBytes = clampInt(pluginCfg.maxPromptBytes, 16_384, 512, 65_536);
317
+
318
+ // Main sage MCP bridge - pass HOME to ensure auth state is found
319
+ sageBridge = new McpBridge(sageBinary, ["mcp", "start"], {
320
+ HOME: homedir(),
321
+ PATH: process.env.PATH || "",
322
+ USER: process.env.USER || "",
323
+ XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME || join(homedir(), ".config"),
324
+ XDG_DATA_HOME: process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"),
256
325
  });
326
+ sageBridge.on("log", (line: string) => api.logger.info(`[sage-mcp] ${line}`));
327
+ sageBridge.on("error", (err: Error) => api.logger.error(`[sage-mcp] ${err.message}`));
257
328
 
258
329
  api.registerService({
259
330
  id: "sage-mcp-bridge",
260
331
  start: async (ctx) => {
261
332
  ctx.logger.info("Starting Sage MCP bridge...");
333
+
334
+ // Start the main sage bridge
262
335
  try {
263
- await bridge!.start();
336
+ await sageBridge!.start();
264
337
  ctx.logger.info("Sage MCP bridge ready");
265
338
 
266
- const tools = await bridge!.listTools();
267
- ctx.logger.info(`Discovered ${tools.length} MCP tools`);
339
+ const tools = await sageBridge!.listTools();
340
+ ctx.logger.info(`Discovered ${tools.length} internal MCP tools`);
268
341
 
269
342
  for (const tool of tools) {
270
- registerMcpTool(api, tool);
343
+ registerMcpTool(api, "sage", sageBridge!, tool);
271
344
  }
272
345
  } catch (err) {
273
346
  ctx.logger.error(
274
- `Failed to start MCP bridge: ${err instanceof Error ? err.message : String(err)}`,
347
+ `Failed to start sage MCP bridge: ${err instanceof Error ? err.message : String(err)}`,
275
348
  );
276
349
  }
350
+
351
+ // Load and start external servers
352
+ const customServers = loadCustomServers();
353
+ ctx.logger.info(`Found ${customServers.length} custom external servers`);
354
+
355
+ for (const server of customServers) {
356
+ try {
357
+ ctx.logger.info(`Starting external server: ${server.name} (${server.id})`);
358
+
359
+ const { command, args } = getServerCommand(server);
360
+ const bridge = new McpBridge(command, args, server.env);
361
+
362
+ bridge.on("log", (line: string) => ctx.logger.info(`[${server.id}] ${line}`));
363
+ bridge.on("error", (err: Error) => ctx.logger.error(`[${server.id}] ${err.message}`));
364
+
365
+ await bridge.start();
366
+ externalBridges.set(server.id, bridge);
367
+
368
+ const tools = await bridge.listTools();
369
+ ctx.logger.info(`[${server.id}] Discovered ${tools.length} tools`);
370
+
371
+ for (const tool of tools) {
372
+ registerMcpTool(api, server.id.replace(/-/g, "_"), bridge, tool);
373
+ }
374
+ } catch (err) {
375
+ ctx.logger.error(
376
+ `Failed to start ${server.name}: ${err instanceof Error ? err.message : String(err)}`,
377
+ );
378
+ }
379
+ }
277
380
  },
278
381
  stop: async (ctx) => {
279
- ctx.logger.info("Stopping Sage MCP bridge...");
280
- await bridge?.stop();
382
+ ctx.logger.info("Stopping Sage MCP bridges...");
383
+
384
+ // Stop external bridges
385
+ for (const [id, bridge] of externalBridges) {
386
+ ctx.logger.info(`Stopping ${id}...`);
387
+ await bridge.stop();
388
+ }
389
+ externalBridges.clear();
390
+
391
+ // Stop main sage bridge
392
+ await sageBridge?.stop();
281
393
  },
282
394
  });
395
+
396
+ // Auto-inject context and suggestions at agent start.
397
+ // This uses OpenClaw's plugin hook API (not internal hooks).
398
+ api.on("before_agent_start", async (event: any) => {
399
+ const prompt = normalizePrompt(typeof event?.prompt === "string" ? event.prompt : "", {
400
+ maxBytes: maxPromptBytes,
401
+ });
402
+ if (!prompt || prompt.length < minPromptLen) {
403
+ return autoInject ? { prependContext: SAGE_CONTEXT } : undefined;
404
+ }
405
+
406
+ let suggestBlock = "";
407
+ if (autoSuggest && sageBridge) {
408
+ try {
409
+ const raw = await sageBridge.callTool("search_skills", {
410
+ query: prompt,
411
+ source: "all",
412
+ limit: Math.max(20, suggestLimit),
413
+ });
414
+ const json = extractJsonFromMcpResult(raw) as any;
415
+ const results = Array.isArray(json?.results) ? (json.results as SkillSearchResult[]) : [];
416
+ suggestBlock = formatSkillSuggestions(results, suggestLimit);
417
+ } catch {
418
+ // Ignore suggestion failures; context injection should still work.
419
+ }
420
+ }
421
+
422
+ const parts: string[] = [];
423
+ if (autoInject) parts.push(SAGE_CONTEXT);
424
+ if (suggestBlock) parts.push(suggestBlock);
425
+
426
+ if (!parts.length) return undefined;
427
+ return { prependContext: parts.join("\n\n") };
428
+ });
283
429
  },
284
430
  };
285
431
 
286
- function registerMcpTool(api: PluginApi, tool: McpToolDef) {
287
- const name = `sage_${tool.name}`;
432
+ function registerMcpTool(api: PluginApi, prefix: string, bridge: McpBridge, tool: McpToolDef) {
433
+ const name = `${prefix}_${tool.name}`;
288
434
  const schema = mcpSchemaToTypebox(tool.inputSchema);
289
435
 
290
436
  api.registerTool(
291
437
  {
292
438
  name,
293
- label: `Sage: ${tool.name}`,
294
- description: tool.description ?? `Sage MCP tool: ${tool.name}`,
439
+ label: `${prefix}: ${tool.name}`,
440
+ description: tool.description ?? `MCP tool: ${prefix}/${tool.name}`,
295
441
  parameters: schema,
296
442
  execute: async (_toolCallId: string, params: Record<string, unknown>) => {
297
- if (!bridge) {
298
- return toToolResult({ error: "MCP bridge not initialized" });
299
- }
300
443
  try {
301
444
  const result = await bridge.callTool(tool.name, params);
302
445
  return toToolResult(result);
@@ -312,3 +455,10 @@ function registerMcpTool(api: PluginApi, tool: McpToolDef) {
312
455
  }
313
456
 
314
457
  export default plugin;
458
+
459
+ export const __test = {
460
+ SAGE_CONTEXT,
461
+ normalizePrompt,
462
+ extractJsonFromMcpResult,
463
+ formatSkillSuggestions,
464
+ };
@@ -4,6 +4,7 @@ import { resolve } from "node:path";
4
4
 
5
5
  import { McpBridge } from "./mcp-bridge.js";
6
6
  import plugin from "./index.js";
7
+ import { __test } from "./index.js";
7
8
 
8
9
  function addSageDebugBinToPath() {
9
10
  // Ensure the `sage` binary used by the plugin resolves to this repo's build.
@@ -79,3 +80,53 @@ test("OpenClaw plugin registers MCP tools via sage mcp start", async () => {
79
80
  });
80
81
  }
81
82
  });
83
+
84
+ test("OpenClaw plugin registers before_agent_start hook and returns prependContext", async () => {
85
+ const hooks: Record<string, any> = {};
86
+
87
+ const api = {
88
+ id: "t",
89
+ name: "t",
90
+ pluginConfig: {},
91
+ logger: {
92
+ info: (_: string) => {},
93
+ warn: (_: string) => {},
94
+ error: (_: string) => {},
95
+ },
96
+ registerTool: (_tool: any) => {},
97
+ registerService: (_svc: any) => {},
98
+ on: (hook: string, handler: any) => {
99
+ hooks[hook] = handler;
100
+ },
101
+ };
102
+
103
+ plugin.register(api as any);
104
+ assert.ok(typeof hooks.before_agent_start === "function", "expected before_agent_start hook");
105
+
106
+ const result = await hooks.before_agent_start({ prompt: "build an mcp server" });
107
+ assert.ok(result && typeof result === "object");
108
+ assert.ok(
109
+ typeof result.prependContext === "string" && result.prependContext.includes("Sage MCP Tools Available"),
110
+ "expected prependContext with Sage tool context",
111
+ );
112
+ });
113
+
114
+ test("formatSkillSuggestions formats stable markdown", () => {
115
+ const out = __test.formatSkillSuggestions(
116
+ [
117
+ {
118
+ key: "bug-bounty",
119
+ name: "Bug Bounty",
120
+ description: "Recon, scanning, API testing",
121
+ source: "installed",
122
+ mcpServers: ["zap"],
123
+ },
124
+ { key: "", name: "skip" },
125
+ ],
126
+ 3,
127
+ );
128
+
129
+ assert.ok(out.includes("## Suggested Skills"));
130
+ assert.ok(out.includes("`use_skill` `bug-bounty`"));
131
+ assert.ok(out.includes("requires: zap"));
132
+ });
package/src/mcp-bridge.ts CHANGED
@@ -45,6 +45,7 @@ export class McpBridge extends EventEmitter {
45
45
  constructor(
46
46
  private command: string,
47
47
  private args: string[],
48
+ private env?: Record<string, string>,
48
49
  ) {
49
50
  super();
50
51
  }
@@ -94,7 +95,7 @@ export class McpBridge extends EventEmitter {
94
95
  return new Promise((resolve, reject) => {
95
96
  const proc = spawn(this.command, this.args, {
96
97
  stdio: ["pipe", "pipe", "pipe"],
97
- env: { ...process.env },
98
+ env: { ...process.env, ...this.env },
98
99
  });
99
100
 
100
101
  proc.on("error", (err) => {
@@ -0,0 +1,255 @@
1
+ /**
2
+ * E2E Test: OpenClaw RLM Capture Path
3
+ *
4
+ * Validates the OpenClaw-specific capture flow:
5
+ * 1. Spawn sage MCP server with isolated HOME
6
+ * 2. Simulate message_received hook (sage capture hook prompt)
7
+ * 3. Simulate agent_end hook (sage capture hook response)
8
+ * 4. Verify captures landed via rlm_stats MCP tool
9
+ * 5. Run rlm_analyze_captures and verify stats update
10
+ */
11
+
12
+ import test from "node:test";
13
+ import assert from "node:assert/strict";
14
+ import { resolve } from "node:path";
15
+ import { mkdtempSync, existsSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { spawn, type ChildProcess } from "node:child_process";
18
+ import { createInterface } from "node:readline";
19
+ import { randomUUID } from "node:crypto";
20
+
21
+ // ── Helpers ──────────────────────────────────────────────────────────
22
+
23
+ const sageBin = resolve(new URL("..", import.meta.url).pathname, "..", "target", "debug", "sage");
24
+
25
+ function createIsolatedHome(): string {
26
+ return mkdtempSync(resolve(tmpdir(), "sage-openclaw-e2e-"));
27
+ }
28
+
29
+ function isolatedEnv(tmpHome: string): Record<string, string> {
30
+ return {
31
+ ...(process.env as Record<string, string>),
32
+ HOME: tmpHome,
33
+ XDG_CONFIG_HOME: resolve(tmpHome, ".config"),
34
+ XDG_DATA_HOME: resolve(tmpHome, ".local/share"),
35
+ SAGE_HOME: resolve(tmpHome, ".sage"),
36
+ };
37
+ }
38
+
39
+ type JsonRpcClient = {
40
+ request: (method: string, params: Record<string, unknown>) => Promise<unknown>;
41
+ notify: (method: string, params: Record<string, unknown>) => void;
42
+ };
43
+
44
+ function createMcpClient(proc: ChildProcess): JsonRpcClient {
45
+ const pending = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
46
+
47
+ if (!proc.stdout) throw new Error("No stdout");
48
+
49
+ const rl = createInterface({ input: proc.stdout });
50
+ rl.on("line", (line) => {
51
+ let msg: any;
52
+ try {
53
+ msg = JSON.parse(line);
54
+ } catch {
55
+ return;
56
+ }
57
+ if (!msg?.id) return;
58
+ const id = String(msg.id);
59
+ const waiter = pending.get(id);
60
+ if (!waiter) return;
61
+ pending.delete(id);
62
+ if (msg.error) waiter.reject(new Error(msg.error.message || "MCP error"));
63
+ else waiter.resolve(msg.result);
64
+ });
65
+
66
+ return {
67
+ request(method, params) {
68
+ if (!proc.stdin?.writable) throw new Error("stdin not writable");
69
+ const id = randomUUID();
70
+ const req = { jsonrpc: "2.0", id, method, params };
71
+ proc.stdin.write(JSON.stringify(req) + "\n");
72
+ return new Promise((resolve, reject) => {
73
+ pending.set(id, { resolve, reject });
74
+ });
75
+ },
76
+ notify(method, params) {
77
+ if (!proc.stdin?.writable) return;
78
+ proc.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n");
79
+ },
80
+ };
81
+ }
82
+
83
+ async function callTool(
84
+ client: JsonRpcClient,
85
+ name: string,
86
+ args: Record<string, unknown> = {},
87
+ ): Promise<any> {
88
+ const result = (await client.request("tools/call", {
89
+ name,
90
+ arguments: args,
91
+ })) as any;
92
+
93
+ const text =
94
+ result?.content
95
+ ?.filter((c: any) => c.type === "text")
96
+ .map((c: any) => c.text)
97
+ .join("\n") ?? "";
98
+
99
+ try {
100
+ return JSON.parse(text);
101
+ } catch {
102
+ return text;
103
+ }
104
+ }
105
+
106
+ function runCaptureCli(
107
+ bin: string,
108
+ subArgs: string[],
109
+ env: Record<string, string>,
110
+ ): Promise<number | null> {
111
+ return new Promise((resolve) => {
112
+ const p = spawn(bin, subArgs, {
113
+ env,
114
+ stdio: ["ignore", "pipe", "pipe"],
115
+ });
116
+ p.on("close", (code) => resolve(code));
117
+ p.on("error", () => resolve(null));
118
+ });
119
+ }
120
+
121
+ // ── Tests ────────────────────────────────────────────────────────────
122
+
123
+ const TIMEOUT = 60_000;
124
+
125
+ test("OpenClaw capture flow: prompt + response -> rlm_stats", { timeout: TIMEOUT }, async () => {
126
+ const tmpHome = createIsolatedHome();
127
+ const env = isolatedEnv(tmpHome);
128
+
129
+ // Start MCP server
130
+ const proc = spawn(sageBin, ["mcp", "start"], {
131
+ stdio: ["pipe", "pipe", "pipe"],
132
+ env,
133
+ });
134
+
135
+ const client = createMcpClient(proc);
136
+
137
+ try {
138
+ // MCP handshake
139
+ const init = await client.request("initialize", {
140
+ protocolVersion: "2024-11-05",
141
+ capabilities: {},
142
+ clientInfo: { name: "openclaw-e2e-test", version: "0.0.0" },
143
+ });
144
+ assert.ok(init, "initialize should return a result");
145
+ client.notify("notifications/initialized", {});
146
+
147
+ // Baseline stats
148
+ const baselineStats = await callTool(client, "rlm_stats");
149
+ assert.ok(baselineStats, "rlm_stats should return a result");
150
+
151
+ // Inject captures mimicking OpenClaw's message_received + agent_end hooks
152
+ const captureEnv = {
153
+ ...env,
154
+ SAGE_SOURCE: "openclaw",
155
+ OPENCLAW: "1",
156
+ };
157
+
158
+ const prompts = [
159
+ {
160
+ prompt: "How to implement a plugin system in TypeScript?",
161
+ response: "Use dynamic imports, define a plugin interface, register plugins at startup.",
162
+ },
163
+ {
164
+ prompt: "Best practices for error handling in Node.js",
165
+ response:
166
+ "Use try-catch with async/await, create custom error classes, use error middleware.",
167
+ },
168
+ {
169
+ prompt: "How to write unit tests with vitest?",
170
+ response:
171
+ "Install vitest, create .test.ts files, use describe/it/expect, run with npx vitest.",
172
+ },
173
+ ];
174
+
175
+ for (const { prompt, response } of prompts) {
176
+ // Phase 1: capture prompt (simulates message_received hook)
177
+ const promptExit = await runCaptureCli(sageBin, ["capture", "hook", "prompt"], {
178
+ ...captureEnv,
179
+ PROMPT: prompt,
180
+ SAGE_SESSION_ID: "openclaw-e2e-session",
181
+ SAGE_MODEL: "gpt-4",
182
+ SAGE_PROVIDER: "openai",
183
+ SAGE_CAPTURE_ATTRIBUTES_JSON: JSON.stringify({
184
+ openclaw: {
185
+ hook: "message_received",
186
+ sessionId: "openclaw-e2e-session",
187
+ channel: "test",
188
+ },
189
+ }),
190
+ });
191
+ // Exit code check (may be non-zero if daemon socket not found, but file-based fallback works)
192
+ assert.ok(promptExit !== null, "prompt capture should not crash");
193
+
194
+ // Phase 2: capture response (simulates agent_end hook)
195
+ const responseExit = await runCaptureCli(sageBin, ["capture", "hook", "response"], {
196
+ ...captureEnv,
197
+ LAST_RESPONSE: response,
198
+ TOKENS_INPUT: "150",
199
+ TOKENS_OUTPUT: "75",
200
+ });
201
+ assert.ok(responseExit !== null, "response capture should not crash");
202
+ }
203
+
204
+ // Run analysis via MCP
205
+ const analysisResult = await callTool(client, "rlm_analyze_captures", {
206
+ goal: "improve developer productivity",
207
+ });
208
+ assert.ok(analysisResult, "rlm_analyze_captures should return a result");
209
+
210
+ // Check patterns
211
+ const patterns = await callTool(client, "rlm_list_patterns", {});
212
+ assert.ok(patterns, "rlm_list_patterns should return a result");
213
+
214
+ // Final stats should reflect some activity
215
+ const finalStats = await callTool(client, "rlm_stats");
216
+ assert.ok(finalStats, "final rlm_stats should return a result");
217
+ } finally {
218
+ proc.kill("SIGTERM");
219
+ }
220
+ });
221
+
222
+ test(
223
+ "OpenClaw capture with custom attributes preserves metadata",
224
+ { timeout: TIMEOUT },
225
+ async () => {
226
+ const tmpHome = createIsolatedHome();
227
+ const env = isolatedEnv(tmpHome);
228
+
229
+ const attrs = {
230
+ openclaw: {
231
+ hook: "message_received",
232
+ sessionId: "test-sess-123",
233
+ sessionKey: "key-456",
234
+ channel: "web",
235
+ senderId: "user-789",
236
+ },
237
+ };
238
+
239
+ // Run capture with rich OpenClaw attributes
240
+ const exitCode = await runCaptureCli(sageBin, ["capture", "hook", "prompt"], {
241
+ ...env,
242
+ SAGE_SOURCE: "openclaw",
243
+ OPENCLAW: "1",
244
+ PROMPT: "Test prompt with rich metadata",
245
+ SAGE_SESSION_ID: "test-sess-123",
246
+ SAGE_MODEL: "claude-3-opus",
247
+ SAGE_PROVIDER: "anthropic",
248
+ SAGE_WORKSPACE: "/workspace/project",
249
+ SAGE_CAPTURE_ATTRIBUTES_JSON: JSON.stringify(attrs),
250
+ });
251
+
252
+ // Should not crash (exit code may be non-zero if daemon not running, that's OK)
253
+ assert.ok(exitCode !== null, "capture with attributes should not crash");
254
+ },
255
+ );
package/tsconfig.json CHANGED
@@ -10,7 +10,8 @@
10
10
  "declaration": true,
11
11
  "outDir": "dist",
12
12
  "rootDir": "src",
13
- "resolveJsonModule": true
13
+ "resolveJsonModule": true,
14
+ "types": ["node"]
14
15
  },
15
16
  "include": ["src/**/*.ts"],
16
17
  "exclude": ["node_modules", "dist"]