@sage-protocol/openclaw-sage 0.1.2 → 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.
- package/.github/workflows/release-please.yml +18 -0
- package/.release-please-manifest.json +3 -0
- package/CHANGELOG.md +34 -0
- package/README.md +44 -13
- package/SOUL.md +64 -0
- package/openclaw.plugin.json +20 -0
- package/package.json +5 -3
- package/release-please-config.json +13 -0
- package/src/index.ts +313 -21
- package/src/mcp-bridge.test.ts +132 -0
- package/src/mcp-bridge.ts +2 -1
- package/src/rlm-capture.e2e.test.ts +255 -0
- package/tsconfig.json +2 -1
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
name: release-please
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: write
|
|
9
|
+
pull-requests: write
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
release-please:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: googleapis/release-please-action@v4
|
|
16
|
+
with:
|
|
17
|
+
config-file: release-please-config.json
|
|
18
|
+
manifest-file: .release-please-manifest.json
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Changelog
|
|
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
|
+
|
|
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)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Features
|
|
21
|
+
|
|
22
|
+
* add Sage capture hooks ([f8fab39](https://github.com/sage-protocol/openclaw-sage/commit/f8fab399860de55d1949c9358a443372f0617eb6))
|
|
23
|
+
* adding ci, release please and improvements ([c32f79a](https://github.com/sage-protocol/openclaw-sage/commit/c32f79a05ee9212d4e382c9131ec684c91706add))
|
|
24
|
+
* adding readme ([4aea6c9](https://github.com/sage-protocol/openclaw-sage/commit/4aea6c950e2dcf276ebcb492cfe70b6ac4cd138e))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Bug Fixes
|
|
28
|
+
|
|
29
|
+
* fixing manifest naming ([28d6add](https://github.com/sage-protocol/openclaw-sage/commit/28d6add05e0b4e60b17993aaf73a23ef55ab1c94))
|
|
30
|
+
* fixing missing openclaw manifest ([5062ae7](https://github.com/sage-protocol/openclaw-sage/commit/5062ae789d2733a209ce2f0c63453779f73245fb))
|
|
31
|
+
|
|
32
|
+
## Changelog
|
|
33
|
+
|
|
34
|
+
All notable changes to this package are documented here.
|
package/README.md
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Sage Plugin (OpenClaw)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
MCP bridge plugin that exposes all Sage Protocol tools inside OpenClaw. Spawns the sage MCP server as a child process and translates JSON-RPC calls into registered OpenClaw tools.
|
|
4
|
+
|
|
5
|
+
## What It Does
|
|
6
|
+
|
|
7
|
+
- **MCP Tool Bridge** - Spawns `sage mcp start` and translates JSON-RPC tool calls into native OpenClaw tools
|
|
8
|
+
- **Dynamic Registration** - Discovers available tools at startup and registers them with typed schemas
|
|
9
|
+
- **RLM Capture** - Records prompt/response pairs for Sage's RLM feedback loop
|
|
10
|
+
- **Crash Recovery** - Automatically restarts the MCP subprocess on unexpected exits
|
|
4
11
|
|
|
5
12
|
## Install
|
|
6
13
|
|
|
@@ -8,11 +15,6 @@ Sage Protocol MCP bridge plugin for OpenClaw. Provides prompt libraries, skills,
|
|
|
8
15
|
openclaw plugins install @sage-protocol/openclaw-sage
|
|
9
16
|
```
|
|
10
17
|
|
|
11
|
-
## Requirements
|
|
12
|
-
|
|
13
|
-
- [Sage CLI](https://github.com/sage-protocol/sage-cli) installed and available on PATH
|
|
14
|
-
- OpenClaw v0.1.0+
|
|
15
|
-
|
|
16
18
|
## Configuration
|
|
17
19
|
|
|
18
20
|
The plugin auto-detects the `sage` binary from PATH. To override:
|
|
@@ -23,15 +25,44 @@ The plugin auto-detects the `sage` binary from PATH. To override:
|
|
|
23
25
|
}
|
|
24
26
|
```
|
|
25
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
|
+
|
|
26
52
|
## What It Provides
|
|
27
53
|
|
|
28
|
-
|
|
54
|
+
Once loaded, all Sage MCP tools are available in OpenClaw:
|
|
55
|
+
|
|
56
|
+
- **Prompts & Libraries** - Search, list, create, and manage prompt libraries
|
|
57
|
+
- **Skills** - Discover and activate skills from Sage Protocol, GitHub, or local sources
|
|
58
|
+
- **Builder** - AI-powered prompt recommendations and synthesis
|
|
59
|
+
- **Governance** - List DAOs, view proposals, check voting power
|
|
60
|
+
- **Hub** - Start/stop additional MCP servers (memory, brave-search, github, etc.)
|
|
29
61
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
-
|
|
33
|
-
-
|
|
34
|
-
- **Hub** — start/stop additional MCP servers (memory, brave-search, github, etc.)
|
|
62
|
+
## Requirements
|
|
63
|
+
|
|
64
|
+
- Sage CLI on PATH
|
|
65
|
+
- OpenClaw v0.1.0+
|
|
35
66
|
|
|
36
67
|
## Development
|
|
37
68
|
|
package/SOUL.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# OpenClaw Agent SOUL
|
|
2
|
+
|
|
3
|
+
This file defines the default behavior for an OpenClaw agent operating in the Sage Protocol ecosystem.
|
|
4
|
+
|
|
5
|
+
## Core Objective
|
|
6
|
+
|
|
7
|
+
Help the user get productive with Sage as fast as possible by reliably steering onboarding toward:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
sage init
|
|
11
|
+
sage agent quickstart
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## When To Prompt For Onboarding
|
|
15
|
+
|
|
16
|
+
Prompt the user to run `sage init` and/or `sage agent quickstart` when any of the following is true:
|
|
17
|
+
|
|
18
|
+
- The user asks about setup, installation, configuration, MCP tools, skills, libraries, or OpenClaw integration.
|
|
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, SXXX tokens needed, IPFS credits missing, library sync failing).
|
|
21
|
+
|
|
22
|
+
## How To Prompt (Non-Spammy)
|
|
23
|
+
|
|
24
|
+
- Ask once, early, and only repeat if the user is blocked by missing setup.
|
|
25
|
+
- Prefer one short message with a single actionable next step.
|
|
26
|
+
- Don't derail the task: if the user is already mid-task, suggest onboarding as a quick fix only when it unblocks progress.
|
|
27
|
+
|
|
28
|
+
## Recommended Message Templates
|
|
29
|
+
|
|
30
|
+
If Sage is not initialized:
|
|
31
|
+
|
|
32
|
+
```text
|
|
33
|
+
Run `sage init` to set up the Sage skill + MCP integration for your environment.
|
|
34
|
+
Then request SXXX tokens: `sage chat send global:agents "request tokens"`
|
|
35
|
+
Then claim IPFS credits: `sage agent quickstart`
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
If Sage is initialized but the user needs tokens/credits:
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
Request SXXX tokens from the bot: `sage chat send global:agents "request tokens"`
|
|
42
|
+
Then claim IPFS credits: `sage agent quickstart`
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
If the user is using OpenClaw and doesn't have MCP tools:
|
|
46
|
+
|
|
47
|
+
```text
|
|
48
|
+
Install the OpenClaw MCP bridge: `openclaw plugins install @sage-protocol/openclaw-sage`.
|
|
49
|
+
Then run: `sage init` and `sage agent quickstart`.
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## OpenClaw Skill Directory
|
|
53
|
+
|
|
54
|
+
OpenClaw loads skills from:
|
|
55
|
+
|
|
56
|
+
```text
|
|
57
|
+
~/.openclaw/workspace/skills/
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Guardrails
|
|
61
|
+
|
|
62
|
+
- Don't instruct `git push`, key export, or any destructive command unless the user explicitly asks.
|
|
63
|
+
- Don't ask the user to paste secrets (tokens, private keys). Use `sage wallet connect -w privy` (OAuth) or documented wallet flows.
|
|
64
|
+
- Prefer the unified `sage suggest ...` family for discovery, but onboarding comes first.
|
package/openclaw.plugin.json
CHANGED
|
@@ -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
|
+
"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",
|
|
@@ -14,11 +14,13 @@
|
|
|
14
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
|
-
"
|
|
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
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
|
|
3
|
+
"release-type": "node",
|
|
4
|
+
"include-v-in-tag": true,
|
|
5
|
+
"bump-minor-pre-major": true,
|
|
6
|
+
"bump-patch-for-minor-pre-major": true,
|
|
7
|
+
"packages": {
|
|
8
|
+
".": {
|
|
9
|
+
"package-name": "@sage-protocol/openclaw-sage",
|
|
10
|
+
"changelog-path": "CHANGELOG.md"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,37 @@
|
|
|
1
1
|
import { Type } from "@sinclair/typebox";
|
|
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";
|
|
2
6
|
|
|
3
7
|
import { McpBridge, type McpToolDef } from "./mcp-bridge.js";
|
|
4
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
|
+
|
|
5
35
|
/**
|
|
6
36
|
* Minimal type stubs for OpenClaw plugin API.
|
|
7
37
|
*
|
|
@@ -25,13 +55,104 @@ type PluginApi = {
|
|
|
25
55
|
id: string;
|
|
26
56
|
name: string;
|
|
27
57
|
logger: PluginLogger;
|
|
58
|
+
pluginConfig?: Record<string, unknown>;
|
|
28
59
|
registerTool: (tool: unknown, opts?: { name?: string; optional?: boolean }) => void;
|
|
29
60
|
registerService: (service: {
|
|
30
61
|
id: string;
|
|
31
62
|
start: (ctx: PluginServiceContext) => void | Promise<void>;
|
|
32
63
|
stop?: (ctx: PluginServiceContext) => void | Promise<void>;
|
|
33
64
|
}) => void;
|
|
34
|
-
on: (hook: string, handler: (...args: unknown[]) =>
|
|
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>;
|
|
35
156
|
};
|
|
36
157
|
|
|
37
158
|
/**
|
|
@@ -96,65 +217,229 @@ function toToolResult(mcpResult: unknown) {
|
|
|
96
217
|
};
|
|
97
218
|
}
|
|
98
219
|
|
|
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");
|
|
225
|
+
|
|
226
|
+
if (!existsSync(configPath)) {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
|
|
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 [];
|
|
246
|
+
}
|
|
247
|
+
|
|
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 [];
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
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}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
99
294
|
// ── Plugin Definition ────────────────────────────────────────────────────────
|
|
100
295
|
|
|
101
|
-
let
|
|
296
|
+
let sageBridge: McpBridge | null = null;
|
|
297
|
+
const externalBridges: Map<string, McpBridge> = new Map();
|
|
102
298
|
|
|
103
299
|
const plugin = {
|
|
104
300
|
id: "openclaw-sage",
|
|
105
301
|
name: "Sage Protocol",
|
|
106
|
-
version: "0.
|
|
302
|
+
version: "0.2.0",
|
|
107
303
|
description:
|
|
108
|
-
"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)",
|
|
109
305
|
|
|
110
306
|
register(api: PluginApi) {
|
|
111
|
-
|
|
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);
|
|
112
317
|
|
|
113
|
-
|
|
114
|
-
|
|
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"),
|
|
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}`));
|
|
115
328
|
|
|
116
329
|
api.registerService({
|
|
117
330
|
id: "sage-mcp-bridge",
|
|
118
331
|
start: async (ctx) => {
|
|
119
332
|
ctx.logger.info("Starting Sage MCP bridge...");
|
|
333
|
+
|
|
334
|
+
// Start the main sage bridge
|
|
120
335
|
try {
|
|
121
|
-
await
|
|
336
|
+
await sageBridge!.start();
|
|
122
337
|
ctx.logger.info("Sage MCP bridge ready");
|
|
123
338
|
|
|
124
|
-
const tools = await
|
|
125
|
-
ctx.logger.info(`Discovered ${tools.length} MCP tools`);
|
|
339
|
+
const tools = await sageBridge!.listTools();
|
|
340
|
+
ctx.logger.info(`Discovered ${tools.length} internal MCP tools`);
|
|
126
341
|
|
|
127
342
|
for (const tool of tools) {
|
|
128
|
-
registerMcpTool(api, tool);
|
|
343
|
+
registerMcpTool(api, "sage", sageBridge!, tool);
|
|
129
344
|
}
|
|
130
345
|
} catch (err) {
|
|
131
346
|
ctx.logger.error(
|
|
132
|
-
`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)}`,
|
|
133
348
|
);
|
|
134
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
|
+
}
|
|
135
380
|
},
|
|
136
381
|
stop: async (ctx) => {
|
|
137
|
-
ctx.logger.info("Stopping Sage MCP
|
|
138
|
-
|
|
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();
|
|
139
393
|
},
|
|
140
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
|
+
});
|
|
141
429
|
},
|
|
142
430
|
};
|
|
143
431
|
|
|
144
|
-
function registerMcpTool(api: PluginApi, tool: McpToolDef) {
|
|
145
|
-
const name =
|
|
432
|
+
function registerMcpTool(api: PluginApi, prefix: string, bridge: McpBridge, tool: McpToolDef) {
|
|
433
|
+
const name = `${prefix}_${tool.name}`;
|
|
146
434
|
const schema = mcpSchemaToTypebox(tool.inputSchema);
|
|
147
435
|
|
|
148
436
|
api.registerTool(
|
|
149
437
|
{
|
|
150
438
|
name,
|
|
151
|
-
label:
|
|
152
|
-
description: tool.description ?? `
|
|
439
|
+
label: `${prefix}: ${tool.name}`,
|
|
440
|
+
description: tool.description ?? `MCP tool: ${prefix}/${tool.name}`,
|
|
153
441
|
parameters: schema,
|
|
154
442
|
execute: async (_toolCallId: string, params: Record<string, unknown>) => {
|
|
155
|
-
if (!bridge) {
|
|
156
|
-
return toToolResult({ error: "MCP bridge not initialized" });
|
|
157
|
-
}
|
|
158
443
|
try {
|
|
159
444
|
const result = await bridge.callTool(tool.name, params);
|
|
160
445
|
return toToolResult(result);
|
|
@@ -170,3 +455,10 @@ function registerMcpTool(api: PluginApi, tool: McpToolDef) {
|
|
|
170
455
|
}
|
|
171
456
|
|
|
172
457
|
export default plugin;
|
|
458
|
+
|
|
459
|
+
export const __test = {
|
|
460
|
+
SAGE_CONTEXT,
|
|
461
|
+
normalizePrompt,
|
|
462
|
+
extractJsonFromMcpResult,
|
|
463
|
+
formatSkillSuggestions,
|
|
464
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { McpBridge } from "./mcp-bridge.js";
|
|
6
|
+
import plugin from "./index.js";
|
|
7
|
+
import { __test } from "./index.js";
|
|
8
|
+
|
|
9
|
+
function addSageDebugBinToPath() {
|
|
10
|
+
// Ensure the `sage` binary used by the plugin resolves to this repo's build.
|
|
11
|
+
const binDir = resolve(new URL("..", import.meta.url).pathname, "..", "target", "debug");
|
|
12
|
+
const sep = process.platform === "win32" ? ";" : ":";
|
|
13
|
+
process.env.PATH = `${binDir}${sep}${process.env.PATH ?? ""}`;
|
|
14
|
+
return { binDir };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test("McpBridge can initialize, list tools, and call a native tool", async () => {
|
|
18
|
+
const sageBin = resolve(new URL("..", import.meta.url).pathname, "..", "target", "debug", "sage");
|
|
19
|
+
const bridge = new McpBridge(sageBin, ["mcp", "start"]);
|
|
20
|
+
await bridge.start();
|
|
21
|
+
try {
|
|
22
|
+
const tools = await bridge.listTools();
|
|
23
|
+
assert.ok(Array.isArray(tools));
|
|
24
|
+
assert.ok(tools.length > 0);
|
|
25
|
+
|
|
26
|
+
const hasProjectContext = tools.some((t) => t.name === "get_project_context");
|
|
27
|
+
assert.ok(hasProjectContext, "expected get_project_context tool to exist");
|
|
28
|
+
|
|
29
|
+
const result = await bridge.callTool("get_project_context", {});
|
|
30
|
+
assert.ok(result && typeof result === "object");
|
|
31
|
+
} finally {
|
|
32
|
+
await bridge.stop();
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("OpenClaw plugin registers MCP tools via sage mcp start", async () => {
|
|
37
|
+
addSageDebugBinToPath();
|
|
38
|
+
|
|
39
|
+
const registeredTools: string[] = [];
|
|
40
|
+
const services: Array<{ id: string; start: Function; stop?: Function }> = [];
|
|
41
|
+
|
|
42
|
+
const api = {
|
|
43
|
+
id: "t",
|
|
44
|
+
name: "t",
|
|
45
|
+
logger: {
|
|
46
|
+
info: (_: string) => {},
|
|
47
|
+
warn: (_: string) => {},
|
|
48
|
+
error: (_: string) => {},
|
|
49
|
+
},
|
|
50
|
+
registerTool: (tool: any) => {
|
|
51
|
+
if (tool?.name) registeredTools.push(tool.name);
|
|
52
|
+
},
|
|
53
|
+
registerService: (svc: any) => {
|
|
54
|
+
services.push(svc);
|
|
55
|
+
},
|
|
56
|
+
on: (_hook: string, _handler: any) => {},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
plugin.register(api);
|
|
60
|
+
const svc = services.find((s) => s.id === "sage-mcp-bridge");
|
|
61
|
+
assert.ok(svc, "expected sage-mcp-bridge service to be registered");
|
|
62
|
+
|
|
63
|
+
await svc!.start({
|
|
64
|
+
config: {},
|
|
65
|
+
stateDir: "/tmp",
|
|
66
|
+
logger: api.logger,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Tool names are prefixed with `sage_` in this plugin.
|
|
70
|
+
assert.ok(
|
|
71
|
+
registeredTools.some((n) => n.startsWith("sage_")),
|
|
72
|
+
"expected at least one sage_* tool",
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
if (svc!.stop) {
|
|
76
|
+
await svc!.stop({
|
|
77
|
+
config: {},
|
|
78
|
+
stateDir: "/tmp",
|
|
79
|
+
logger: api.logger,
|
|
80
|
+
});
|
|
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
|
+
);
|