@sage-protocol/openclaw-sage 0.1.6 → 0.1.8
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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +31 -0
- package/README.md +128 -69
- package/SOUL.md +109 -1
- package/openclaw.plugin.json +31 -1
- package/package.json +3 -3
- package/src/index.ts +631 -254
- package/src/mcp-bridge.test.ts +220 -33
- package/src/openclaw-hook.integration.test.ts +258 -0
- package/src/rlm-capture.e2e.test.ts +33 -9
package/src/index.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { Type, type TSchema } from "@sinclair/typebox";
|
|
2
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
3
4
|
import { homedir } from "node:os";
|
|
4
5
|
import { join, resolve, dirname } from "node:path";
|
|
5
6
|
import { createHash } from "node:crypto";
|
|
6
7
|
import { fileURLToPath } from "node:url";
|
|
7
|
-
import TOML from "@iarna/toml";
|
|
8
8
|
|
|
9
|
-
import { McpBridge
|
|
9
|
+
import { McpBridge } from "./mcp-bridge.js";
|
|
10
10
|
|
|
11
11
|
// Read version from package.json at module load time
|
|
12
12
|
const __dirname_compat = dirname(fileURLToPath(import.meta.url));
|
|
@@ -19,59 +19,31 @@ const PKG_VERSION: string = (() => {
|
|
|
19
19
|
}
|
|
20
20
|
})();
|
|
21
21
|
|
|
22
|
-
const SAGE_CONTEXT = `## Sage
|
|
22
|
+
const SAGE_CONTEXT = `## Sage (Code Mode)
|
|
23
23
|
|
|
24
|
-
You have access to Sage
|
|
24
|
+
You have access to Sage through a consolidated Code Mode interface.
|
|
25
|
+
Sage internal domains are available immediately through Code Mode.
|
|
26
|
+
Only external MCP servers need lifecycle management outside Code Mode: start/stop them with Sage CLI,
|
|
27
|
+
the Sage app, or raw MCP \`hub_*\` tools, then use \`domain: "external"\` here.
|
|
25
28
|
|
|
26
|
-
###
|
|
27
|
-
- \`
|
|
28
|
-
- \`
|
|
29
|
-
- \`get_prompt\` - Get full prompt content by key
|
|
30
|
-
- \`builder_recommend\` - AI-powered prompt suggestions based on intent
|
|
31
|
-
- \`builder_synthesize\` - Create new prompts from a description
|
|
29
|
+
### Core Tools
|
|
30
|
+
- \`sage_search\` — Read-only search across Sage domains. Params: \`{domain, action, params}\`
|
|
31
|
+
- \`sage_execute\` — Mutations across Sage domains. Same params.
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
- \`search_skills\` / \`list_skills\` - Find available skills
|
|
35
|
-
- \`get_skill\` - Get skill details and content
|
|
36
|
-
- \`use_skill\` - Activate a skill (auto-provisions required MCP servers)
|
|
37
|
-
- \`sync_skills\` - Sync skills from daemon
|
|
33
|
+
Domains: prompts, skills, builder, governance, chat, social, rlm, library_sync, security, meta, help, external
|
|
38
34
|
|
|
39
|
-
|
|
40
|
-
-
|
|
41
|
-
-
|
|
42
|
-
-
|
|
43
|
-
-
|
|
35
|
+
Examples:
|
|
36
|
+
- Discover actions: sage_search { domain: "help", action: "list", params: {} }
|
|
37
|
+
- Search prompts: sage_search { domain: "prompts", action: "search", params: { query: "..." } }
|
|
38
|
+
- Use a skill: sage_execute { domain: "skills", action: "use", params: { key: "..." } }
|
|
39
|
+
- Project context: sage_search { domain: "meta", action: "get_project_context", params: {} }
|
|
40
|
+
- Inspect running external servers: sage_search { domain: "external", action: "list_servers" }
|
|
41
|
+
- Call an external tool (auto-route): sage_execute { domain: "external", action: "call", params: { tool_name: "<tool>", tool_params: {...} } }
|
|
42
|
+
- Execute an external tool (explicit): sage_execute { domain: "external", action: "execute", params: { server_id: "<id>", tool_name: "<tool>", tool_params: {...} } }`;
|
|
44
43
|
|
|
45
|
-
|
|
46
|
-
- \`sage_list_tips\` / \`sage_list_tip_stats\` - Tips activity and stats
|
|
47
|
-
- \`sage_list_bounties\` - Open/completed bounties
|
|
48
|
-
- \`sage_list_bounty_library_additions\` - Pending library merges
|
|
44
|
+
const SAGE_STATUS_CONTEXT = `\n\nPlugin meta-tool:\n- \`sage_status\` - show bridge health + wallet/network context`;
|
|
49
45
|
|
|
50
|
-
|
|
51
|
-
- \`chat_list_rooms\` / \`chat_send\` / \`chat_history\` - Real-time messaging
|
|
52
|
-
- Social follow/unfollow (via CLI)
|
|
53
|
-
|
|
54
|
-
### RLM (Recursive Language Model)
|
|
55
|
-
- \`rlm_stats\` - RLM statistics and capture counts
|
|
56
|
-
- \`rlm_analyze_captures\` - Analyze captured prompt/response pairs
|
|
57
|
-
- \`rlm_list_patterns\` - Show discovered patterns
|
|
58
|
-
|
|
59
|
-
### Memory & Knowledge Graph
|
|
60
|
-
- \`memory_create_entities\` / \`memory_search_nodes\` / \`memory_read_graph\` - Knowledge graph ops
|
|
61
|
-
|
|
62
|
-
### External Tools (via Hub)
|
|
63
|
-
- \`hub_list_servers\` - List available MCP servers (memory, github, brave, etc.)
|
|
64
|
-
- \`hub_start_server\` - Start an MCP server to gain access to its tools
|
|
65
|
-
- \`hub_status\` - Check which servers are currently running
|
|
66
|
-
|
|
67
|
-
### Plugin Status
|
|
68
|
-
- \`sage_status\` - Check bridge health, connected network, wallet, and tool count
|
|
69
|
-
|
|
70
|
-
### Best Practices
|
|
71
|
-
1. **Search before implementing** - Use \`search_prompts\` or \`builder_recommend\` to find existing solutions
|
|
72
|
-
2. **Use skills for complex tasks** - Skills bundle prompts + MCP servers for specific workflows
|
|
73
|
-
3. **Start additional servers as needed** - Use \`hub_start_server\` for memory, github, brave search, etc.
|
|
74
|
-
4. **Check skill requirements** - Skills may require specific MCP servers; \`use_skill\` auto-provisions them`;
|
|
46
|
+
const SAGE_FULL_CONTEXT = `${SAGE_CONTEXT}${SAGE_STATUS_CONTEXT}`;
|
|
75
47
|
|
|
76
48
|
/**
|
|
77
49
|
* Minimal type stubs for OpenClaw plugin API.
|
|
@@ -159,7 +131,11 @@ function sha256Hex(s: string): string {
|
|
|
159
131
|
|
|
160
132
|
type SecurityScanResult = {
|
|
161
133
|
shouldBlock?: boolean;
|
|
162
|
-
report?: {
|
|
134
|
+
report?: {
|
|
135
|
+
level?: string;
|
|
136
|
+
issue_count?: number;
|
|
137
|
+
issues?: Array<{ rule_id?: string; category?: string; severity?: string }>;
|
|
138
|
+
};
|
|
163
139
|
promptGuard?: { finding?: { detected?: boolean; type?: string; confidence?: number } };
|
|
164
140
|
};
|
|
165
141
|
|
|
@@ -203,26 +179,222 @@ function formatSkillSuggestions(results: SkillSearchResult[], limit: number): st
|
|
|
203
179
|
for (const r of items) {
|
|
204
180
|
const key = r.key!.trim();
|
|
205
181
|
const desc = typeof r.description === "string" ? r.description.trim() : "";
|
|
206
|
-
const origin =
|
|
207
|
-
|
|
208
|
-
|
|
182
|
+
const origin =
|
|
183
|
+
typeof r.library === "string" && r.library.trim() ? ` (from ${r.library.trim()})` : "";
|
|
184
|
+
const servers =
|
|
185
|
+
Array.isArray(r.mcpServers) && r.mcpServers.length
|
|
186
|
+
? ` — requires: ${r.mcpServers.join(", ")}`
|
|
187
|
+
: "";
|
|
188
|
+
lines.push(
|
|
189
|
+
`- \`sage_execute\` { "domain": "skills", "action": "use", "params": { "key": "${key}" } }${origin}${desc ? `: ${desc}` : ""}${servers}`,
|
|
190
|
+
);
|
|
209
191
|
}
|
|
210
192
|
return lines.join("\n");
|
|
211
193
|
}
|
|
212
194
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
195
|
+
function isHeartbeatPrompt(prompt: string): boolean {
|
|
196
|
+
return (
|
|
197
|
+
prompt.includes("Sage Protocol Heartbeat") ||
|
|
198
|
+
prompt.includes("HEARTBEAT_OK") ||
|
|
199
|
+
prompt.includes("Heartbeat Checklist")
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const heartbeatSuggestState = {
|
|
204
|
+
lastFullAnalysisTs: 0,
|
|
205
|
+
lastSuggestions: "",
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
async function gatherHeartbeatContext(
|
|
209
|
+
bridge: McpBridge,
|
|
210
|
+
logger: PluginLogger,
|
|
211
|
+
maxChars: number,
|
|
212
|
+
): Promise<string> {
|
|
213
|
+
const parts: string[] = [];
|
|
214
|
+
|
|
215
|
+
// 1) Query RLM patterns
|
|
216
|
+
try {
|
|
217
|
+
const raw = await bridge.callTool("sage_search", {
|
|
218
|
+
domain: "rlm",
|
|
219
|
+
action: "list_patterns",
|
|
220
|
+
params: {},
|
|
221
|
+
});
|
|
222
|
+
const json = extractJsonFromMcpResult(raw);
|
|
223
|
+
if (json) parts.push(`RLM patterns: ${JSON.stringify(json)}`);
|
|
224
|
+
} catch (err) {
|
|
225
|
+
logger.warn(
|
|
226
|
+
`[heartbeat-context] RLM query failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// 2) Read recent daily notes (last 2 days)
|
|
231
|
+
try {
|
|
232
|
+
const memoryDir = join(homedir(), ".openclaw", "memory");
|
|
233
|
+
if (existsSync(memoryDir)) {
|
|
234
|
+
const now = new Date();
|
|
235
|
+
const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60_000);
|
|
236
|
+
const files = readdirSync(memoryDir)
|
|
237
|
+
.filter((f) => /^\d{4}-.*\.md$/.test(f))
|
|
238
|
+
.sort()
|
|
239
|
+
.reverse();
|
|
240
|
+
|
|
241
|
+
for (const file of files.slice(0, 4)) {
|
|
242
|
+
const dateMatch = file.match(/^(\d{4}-\d{2}-\d{2})/);
|
|
243
|
+
if (dateMatch) {
|
|
244
|
+
const fileDate = new Date(dateMatch[1]);
|
|
245
|
+
if (fileDate < twoDaysAgo) continue;
|
|
246
|
+
}
|
|
247
|
+
const content = readFileSync(join(memoryDir, file), "utf8").trim();
|
|
248
|
+
if (content) parts.push(`--- ${file} ---\n${content}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
} catch (err) {
|
|
252
|
+
logger.warn(
|
|
253
|
+
`[heartbeat-context] memory read failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const combined = parts.join("\n\n");
|
|
258
|
+
return combined.length > maxChars ? combined.slice(0, maxChars) : combined;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function searchSkillsForContext(
|
|
262
|
+
bridge: McpBridge,
|
|
263
|
+
context: string,
|
|
264
|
+
suggestLimit: number,
|
|
265
|
+
logger: PluginLogger,
|
|
266
|
+
): Promise<string> {
|
|
267
|
+
const results: SkillSearchResult[] = [];
|
|
268
|
+
|
|
269
|
+
// Search skills against the context
|
|
270
|
+
try {
|
|
271
|
+
const raw = await bridge.callTool("sage_search", {
|
|
272
|
+
domain: "skills",
|
|
273
|
+
action: "search",
|
|
274
|
+
params: {
|
|
275
|
+
query: context,
|
|
276
|
+
source: "all",
|
|
277
|
+
limit: Math.max(20, suggestLimit),
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
const json = extractJsonFromMcpResult(raw) as any;
|
|
281
|
+
if (Array.isArray(json?.results)) results.push(...json.results);
|
|
282
|
+
} catch (err) {
|
|
283
|
+
logger.warn(
|
|
284
|
+
`[heartbeat-context] skill search failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Also try builder recommendations
|
|
289
|
+
try {
|
|
290
|
+
const raw = await bridge.callTool("sage_search", {
|
|
291
|
+
domain: "builder",
|
|
292
|
+
action: "recommend",
|
|
293
|
+
params: { query: context },
|
|
294
|
+
});
|
|
295
|
+
const json = extractJsonFromMcpResult(raw) as any;
|
|
296
|
+
if (Array.isArray(json?.results)) {
|
|
297
|
+
for (const r of json.results) {
|
|
298
|
+
if (r?.key && !results.some((e) => e.key === r.key)) results.push(r);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
} catch {
|
|
302
|
+
// Builder recommend is optional.
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const formatted = formatSkillSuggestions(results, suggestLimit);
|
|
306
|
+
return formatted ? `## Context-Aware Skill Suggestions\n\n${formatted}` : "";
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function pickFirstString(...values: unknown[]): string {
|
|
310
|
+
for (const value of values) {
|
|
311
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
312
|
+
}
|
|
313
|
+
return "";
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function extractEventPrompt(event: any): string {
|
|
317
|
+
return pickFirstString(
|
|
318
|
+
event?.prompt,
|
|
319
|
+
event?.input,
|
|
320
|
+
event?.message?.content,
|
|
321
|
+
event?.message?.text,
|
|
322
|
+
event?.text,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function extractEventResponse(event: any): string {
|
|
327
|
+
const responseObj =
|
|
328
|
+
typeof event?.response === "object" && event?.response ? event.response : undefined;
|
|
329
|
+
const outputObj = typeof event?.output === "object" && event?.output ? event.output : undefined;
|
|
330
|
+
return pickFirstString(
|
|
331
|
+
event?.response,
|
|
332
|
+
responseObj?.content,
|
|
333
|
+
responseObj?.text,
|
|
334
|
+
responseObj?.message,
|
|
335
|
+
event?.output,
|
|
336
|
+
outputObj?.content,
|
|
337
|
+
outputObj?.text,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function extractEventSessionId(event: any): string {
|
|
342
|
+
return pickFirstString(event?.sessionId, event?.sessionID, event?.conversationId);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function extractEventModel(event: any): string {
|
|
346
|
+
const modelObj = typeof event?.model === "object" && event?.model ? event.model : undefined;
|
|
347
|
+
return pickFirstString(
|
|
348
|
+
event?.modelId,
|
|
349
|
+
modelObj?.modelID,
|
|
350
|
+
modelObj?.modelId,
|
|
351
|
+
modelObj?.id,
|
|
352
|
+
typeof event?.model === "string" ? event.model : "",
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function extractEventProvider(event: any): string {
|
|
357
|
+
const modelObj = typeof event?.model === "object" && event?.model ? event.model : undefined;
|
|
358
|
+
return pickFirstString(
|
|
359
|
+
event?.provider,
|
|
360
|
+
event?.providerId,
|
|
361
|
+
modelObj?.providerID,
|
|
362
|
+
modelObj?.providerId,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function extractEventTokenCount(event: any, phase: "input" | "output"): string {
|
|
367
|
+
const value =
|
|
368
|
+
event?.tokens?.[phase] ??
|
|
369
|
+
event?.usage?.[`${phase}_tokens`] ??
|
|
370
|
+
event?.usage?.[phase] ??
|
|
371
|
+
event?.metrics?.[`${phase}Tokens`];
|
|
372
|
+
if (value == null) return "";
|
|
373
|
+
return String(value);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const SageDomain = Type.Union(
|
|
377
|
+
[
|
|
378
|
+
Type.Literal("prompts"),
|
|
379
|
+
Type.Literal("skills"),
|
|
380
|
+
Type.Literal("builder"),
|
|
381
|
+
Type.Literal("governance"),
|
|
382
|
+
Type.Literal("chat"),
|
|
383
|
+
Type.Literal("social"),
|
|
384
|
+
Type.Literal("rlm"),
|
|
385
|
+
Type.Literal("library_sync"),
|
|
386
|
+
Type.Literal("security"),
|
|
387
|
+
Type.Literal("meta"),
|
|
388
|
+
Type.Literal("help"),
|
|
389
|
+
Type.Literal("external"),
|
|
390
|
+
],
|
|
391
|
+
{ description: "Sage domain namespace" },
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
type SageCodeModeRequest = {
|
|
395
|
+
domain: string;
|
|
396
|
+
action: string;
|
|
397
|
+
params?: Record<string, unknown>;
|
|
226
398
|
};
|
|
227
399
|
|
|
228
400
|
/**
|
|
@@ -237,7 +409,9 @@ function jsonSchemaToTypebox(prop: Record<string, unknown>): TSchema {
|
|
|
237
409
|
// Enum support: string enums become Type.Union of Type.Literal
|
|
238
410
|
if (Array.isArray(prop.enum) && prop.enum.length > 0) {
|
|
239
411
|
const literals = prop.enum
|
|
240
|
-
.filter((v): v is string | number | boolean =>
|
|
412
|
+
.filter((v): v is string | number | boolean =>
|
|
413
|
+
["string", "number", "boolean"].includes(typeof v),
|
|
414
|
+
)
|
|
241
415
|
.map((v) => Type.Literal(v));
|
|
242
416
|
if (literals.length > 0) {
|
|
243
417
|
return literals.length === 1 ? literals[0] : Type.Union(literals, opts);
|
|
@@ -253,7 +427,8 @@ function jsonSchemaToTypebox(prop: Record<string, unknown>): TSchema {
|
|
|
253
427
|
case "array": {
|
|
254
428
|
// Typed array items
|
|
255
429
|
const items = prop.items as Record<string, unknown> | undefined;
|
|
256
|
-
const itemType =
|
|
430
|
+
const itemType =
|
|
431
|
+
items && typeof items === "object" ? jsonSchemaToTypebox(items) : Type.Unknown();
|
|
257
432
|
return Type.Array(itemType, opts);
|
|
258
433
|
}
|
|
259
434
|
case "object": {
|
|
@@ -321,97 +496,53 @@ function toToolResult(mcpResult: unknown) {
|
|
|
321
496
|
/**
|
|
322
497
|
* Load custom server configurations from ~/.config/sage/mcp-servers.toml
|
|
323
498
|
*/
|
|
324
|
-
function
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
try {
|
|
332
|
-
const content = readFileSync(configPath, "utf8");
|
|
333
|
-
const config = TOML.parse(content) as {
|
|
334
|
-
custom?: Record<string, {
|
|
335
|
-
id: string;
|
|
336
|
-
name: string;
|
|
337
|
-
description?: string;
|
|
338
|
-
enabled: boolean;
|
|
339
|
-
source: { type: string; package?: string; path?: string };
|
|
340
|
-
extra_args?: string[];
|
|
341
|
-
env?: Record<string, string>;
|
|
342
|
-
}>;
|
|
343
|
-
};
|
|
344
|
-
|
|
345
|
-
if (!config.custom) {
|
|
346
|
-
return [];
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
return Object.values(config.custom)
|
|
350
|
-
.filter((s) => s.enabled)
|
|
351
|
-
.map((s) => ({
|
|
352
|
-
id: s.id,
|
|
353
|
-
name: s.name,
|
|
354
|
-
description: s.description,
|
|
355
|
-
enabled: s.enabled,
|
|
356
|
-
source: {
|
|
357
|
-
type: s.source.type as "npx" | "node" | "binary",
|
|
358
|
-
package: s.source.package,
|
|
359
|
-
path: s.source.path,
|
|
360
|
-
},
|
|
361
|
-
extra_args: s.extra_args,
|
|
362
|
-
env: s.env,
|
|
363
|
-
}));
|
|
364
|
-
} catch (err) {
|
|
365
|
-
console.error(`Failed to parse mcp-servers.toml: ${err}`);
|
|
366
|
-
return [];
|
|
499
|
+
async function sageSearch(req: SageCodeModeRequest): Promise<unknown> {
|
|
500
|
+
if (!sageBridge?.isReady()) {
|
|
501
|
+
throw new Error(
|
|
502
|
+
"MCP bridge not connected. The sage subprocess may have crashed — try restarting the plugin.",
|
|
503
|
+
);
|
|
367
504
|
}
|
|
505
|
+
return sageBridge.callTool("sage_search", {
|
|
506
|
+
domain: req.domain,
|
|
507
|
+
action: req.action,
|
|
508
|
+
params: req.params ?? {},
|
|
509
|
+
});
|
|
368
510
|
}
|
|
369
511
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
case "npx":
|
|
376
|
-
return {
|
|
377
|
-
command: "npx",
|
|
378
|
-
args: ["-y", server.source.package!, ...(server.extra_args || [])],
|
|
379
|
-
};
|
|
380
|
-
case "node":
|
|
381
|
-
return {
|
|
382
|
-
command: "node",
|
|
383
|
-
args: [server.source.path!, ...(server.extra_args || [])],
|
|
384
|
-
};
|
|
385
|
-
case "binary":
|
|
386
|
-
return {
|
|
387
|
-
command: server.source.path!,
|
|
388
|
-
args: server.extra_args || [],
|
|
389
|
-
};
|
|
390
|
-
default:
|
|
391
|
-
throw new Error(`Unknown source type: ${server.source.type}`);
|
|
512
|
+
async function sageExecute(req: SageCodeModeRequest): Promise<unknown> {
|
|
513
|
+
if (!sageBridge?.isReady()) {
|
|
514
|
+
throw new Error(
|
|
515
|
+
"MCP bridge not connected. The sage subprocess may have crashed — try restarting the plugin.",
|
|
516
|
+
);
|
|
392
517
|
}
|
|
518
|
+
return sageBridge.callTool("sage_execute", {
|
|
519
|
+
domain: req.domain,
|
|
520
|
+
action: req.action,
|
|
521
|
+
params: req.params ?? {},
|
|
522
|
+
});
|
|
393
523
|
}
|
|
394
524
|
|
|
395
525
|
// ── Plugin Definition ────────────────────────────────────────────────────────
|
|
396
526
|
|
|
397
527
|
let sageBridge: McpBridge | null = null;
|
|
398
|
-
const externalBridges: Map<string, McpBridge> = new Map();
|
|
399
528
|
|
|
400
529
|
const plugin = {
|
|
401
530
|
id: "openclaw-sage",
|
|
402
531
|
name: "Sage Protocol",
|
|
403
532
|
version: PKG_VERSION,
|
|
404
533
|
description:
|
|
405
|
-
"Sage MCP tools for
|
|
534
|
+
"Sage MCP tools for prompts, skills, governance, and external tool routing after hub-managed servers are started",
|
|
406
535
|
|
|
407
536
|
register(api: PluginApi) {
|
|
408
537
|
const pluginCfg = api.pluginConfig ?? {};
|
|
409
|
-
const sageBinary =
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
538
|
+
const sageBinary =
|
|
539
|
+
typeof pluginCfg.sageBinary === "string" && pluginCfg.sageBinary.trim()
|
|
540
|
+
? pluginCfg.sageBinary.trim()
|
|
541
|
+
: "sage";
|
|
542
|
+
const sageProfile =
|
|
543
|
+
typeof pluginCfg.sageProfile === "string" && pluginCfg.sageProfile.trim()
|
|
544
|
+
? pluginCfg.sageProfile.trim()
|
|
545
|
+
: undefined;
|
|
415
546
|
|
|
416
547
|
const autoInject = pluginCfg.autoInjectContext !== false;
|
|
417
548
|
const autoSuggest = pluginCfg.autoSuggestSkills !== false;
|
|
@@ -419,6 +550,17 @@ const plugin = {
|
|
|
419
550
|
const minPromptLen = clampInt(pluginCfg.minPromptLen, 12, 0, 500);
|
|
420
551
|
const maxPromptBytes = clampInt(pluginCfg.maxPromptBytes, 16_384, 512, 65_536);
|
|
421
552
|
|
|
553
|
+
// Heartbeat context-aware suggestions
|
|
554
|
+
const heartbeatContextSuggest = pluginCfg.heartbeatContextSuggest !== false;
|
|
555
|
+
const heartbeatSuggestCooldownMs =
|
|
556
|
+
clampInt(pluginCfg.heartbeatSuggestCooldownMinutes, 90, 10, 1440) * 60_000;
|
|
557
|
+
const heartbeatContextMaxChars = clampInt(
|
|
558
|
+
pluginCfg.heartbeatContextMaxChars,
|
|
559
|
+
4000,
|
|
560
|
+
500,
|
|
561
|
+
16_000,
|
|
562
|
+
);
|
|
563
|
+
|
|
422
564
|
// Injection guard (opt-in)
|
|
423
565
|
const injectionGuardEnabled = pluginCfg.injectionGuardEnabled === true;
|
|
424
566
|
const injectionGuardMode = pluginCfg.injectionGuardMode === "block" ? "block" : "warn";
|
|
@@ -428,9 +570,21 @@ const plugin = {
|
|
|
428
570
|
const injectionGuardScanGetPrompt = injectionGuardEnabled
|
|
429
571
|
? pluginCfg.injectionGuardScanGetPrompt !== false
|
|
430
572
|
: false;
|
|
431
|
-
const injectionGuardUsePromptGuard =
|
|
573
|
+
const injectionGuardUsePromptGuard =
|
|
574
|
+
injectionGuardEnabled && pluginCfg.injectionGuardUsePromptGuard === true;
|
|
432
575
|
const injectionGuardMaxChars = clampInt(pluginCfg.injectionGuardMaxChars, 32_768, 256, 200_000);
|
|
433
|
-
const injectionGuardIncludeEvidence =
|
|
576
|
+
const injectionGuardIncludeEvidence =
|
|
577
|
+
injectionGuardEnabled && pluginCfg.injectionGuardIncludeEvidence === true;
|
|
578
|
+
|
|
579
|
+
// Soul stream sync: read locally-synced soul document if configured
|
|
580
|
+
const soulStreamDao =
|
|
581
|
+
typeof pluginCfg.soulStreamDao === "string" && pluginCfg.soulStreamDao.trim()
|
|
582
|
+
? pluginCfg.soulStreamDao.trim().toLowerCase()
|
|
583
|
+
: "";
|
|
584
|
+
const soulStreamLibraryId =
|
|
585
|
+
typeof pluginCfg.soulStreamLibraryId === "string" && pluginCfg.soulStreamLibraryId.trim()
|
|
586
|
+
? pluginCfg.soulStreamLibraryId.trim()
|
|
587
|
+
: "soul";
|
|
434
588
|
|
|
435
589
|
const scanCache = new Map<string, { ts: number; scan: SecurityScanResult }>();
|
|
436
590
|
const SCAN_CACHE_LIMIT = 256;
|
|
@@ -447,12 +601,16 @@ const plugin = {
|
|
|
447
601
|
if (cached && now - cached.ts < SCAN_CACHE_TTL_MS) return cached.scan;
|
|
448
602
|
|
|
449
603
|
try {
|
|
450
|
-
const raw = await
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
604
|
+
const raw = await sageSearch({
|
|
605
|
+
domain: "security",
|
|
606
|
+
action: "scan",
|
|
607
|
+
params: {
|
|
608
|
+
text: trimmed,
|
|
609
|
+
maxChars: injectionGuardMaxChars,
|
|
610
|
+
maxEvidenceLen: 100,
|
|
611
|
+
includeEvidence: injectionGuardIncludeEvidence,
|
|
612
|
+
usePromptGuard: injectionGuardUsePromptGuard,
|
|
613
|
+
},
|
|
456
614
|
});
|
|
457
615
|
const json = extractJsonFromMcpResult(raw) as any;
|
|
458
616
|
const scan: SecurityScanResult = (json && typeof json === "object" ? json : {}) as any;
|
|
@@ -479,9 +637,14 @@ const plugin = {
|
|
|
479
637
|
};
|
|
480
638
|
// Pass through Sage-specific env vars when set
|
|
481
639
|
const passthroughVars = [
|
|
482
|
-
"SAGE_PROFILE",
|
|
483
|
-
"
|
|
484
|
-
"
|
|
640
|
+
"SAGE_PROFILE",
|
|
641
|
+
"SAGE_PAY_TO_PIN",
|
|
642
|
+
"SAGE_IPFS_WORKER_URL",
|
|
643
|
+
"SAGE_IPFS_UPLOAD_TOKEN",
|
|
644
|
+
"SAGE_API_URL",
|
|
645
|
+
"SAGE_HOME",
|
|
646
|
+
"KEYSTORE_PASSWORD",
|
|
647
|
+
"SAGE_PROMPT_GUARD_API_KEY",
|
|
485
648
|
];
|
|
486
649
|
for (const key of passthroughVars) {
|
|
487
650
|
if (process.env[key]) sageEnv[key] = process.env[key]!;
|
|
@@ -489,6 +652,137 @@ const plugin = {
|
|
|
489
652
|
// Config-level profile override takes precedence
|
|
490
653
|
if (sageProfile) sageEnv.SAGE_PROFILE = sageProfile;
|
|
491
654
|
|
|
655
|
+
// ── Capture hooks (best-effort) ───────────────────────────────────
|
|
656
|
+
// These run the CLI capture hook in a child process. They are intentionally
|
|
657
|
+
// non-blocking for agent UX; failures are logged and ignored.
|
|
658
|
+
const captureHooksEnabled = process.env.SAGE_CAPTURE_HOOKS !== "0";
|
|
659
|
+
const CAPTURE_TIMEOUT_MS = 8_000;
|
|
660
|
+
const captureState = {
|
|
661
|
+
sessionId: "",
|
|
662
|
+
model: "",
|
|
663
|
+
provider: "",
|
|
664
|
+
lastPromptHash: "",
|
|
665
|
+
lastPromptTs: 0,
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
const runCaptureHook = async (
|
|
669
|
+
phase: "prompt" | "response",
|
|
670
|
+
extraEnv: Record<string, string>,
|
|
671
|
+
): Promise<void> => {
|
|
672
|
+
await new Promise<void>((resolve, reject) => {
|
|
673
|
+
const child = spawn(sageBinary, ["capture", "hook", phase], {
|
|
674
|
+
env: { ...process.env, ...sageEnv, ...extraEnv },
|
|
675
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
let stderr = "";
|
|
679
|
+
child.stderr?.on("data", (chunk) => {
|
|
680
|
+
stderr += chunk.toString();
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
const timer = setTimeout(() => {
|
|
684
|
+
child.kill("SIGKILL");
|
|
685
|
+
reject(new Error(`capture hook timeout (${phase})`));
|
|
686
|
+
}, CAPTURE_TIMEOUT_MS);
|
|
687
|
+
|
|
688
|
+
child.on("error", (err) => {
|
|
689
|
+
clearTimeout(timer);
|
|
690
|
+
reject(err);
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
child.on("close", (code) => {
|
|
694
|
+
clearTimeout(timer);
|
|
695
|
+
if (code === 0 || code === null) {
|
|
696
|
+
resolve();
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
reject(
|
|
700
|
+
new Error(`capture hook exited with code ${code}${stderr ? `: ${stderr.trim()}` : ""}`),
|
|
701
|
+
);
|
|
702
|
+
});
|
|
703
|
+
});
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
const capturePromptFromEvent = (hookName: string, event: any): void => {
|
|
707
|
+
if (!captureHooksEnabled) return;
|
|
708
|
+
|
|
709
|
+
const prompt = normalizePrompt(extractEventPrompt(event), { maxBytes: maxPromptBytes });
|
|
710
|
+
if (!prompt) return;
|
|
711
|
+
|
|
712
|
+
const sessionId = extractEventSessionId(event);
|
|
713
|
+
const model = extractEventModel(event);
|
|
714
|
+
const provider = extractEventProvider(event);
|
|
715
|
+
|
|
716
|
+
const promptHash = sha256Hex(`${sessionId}:${prompt}`);
|
|
717
|
+
const now = Date.now();
|
|
718
|
+
if (captureState.lastPromptHash === promptHash && now - captureState.lastPromptTs < 2_000) {
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
captureState.lastPromptHash = promptHash;
|
|
722
|
+
captureState.lastPromptTs = now;
|
|
723
|
+
captureState.sessionId = sessionId || captureState.sessionId;
|
|
724
|
+
captureState.model = model || captureState.model;
|
|
725
|
+
captureState.provider = provider || captureState.provider;
|
|
726
|
+
|
|
727
|
+
const attributes = {
|
|
728
|
+
openclaw: {
|
|
729
|
+
hook: hookName,
|
|
730
|
+
sessionId: sessionId || undefined,
|
|
731
|
+
},
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
void runCaptureHook("prompt", {
|
|
735
|
+
SAGE_SOURCE: "openclaw",
|
|
736
|
+
OPENCLAW: "1",
|
|
737
|
+
PROMPT: prompt,
|
|
738
|
+
SAGE_SESSION_ID: sessionId || "",
|
|
739
|
+
SAGE_MODEL: model || "",
|
|
740
|
+
SAGE_PROVIDER: provider || "",
|
|
741
|
+
SAGE_CAPTURE_ATTRIBUTES_JSON: JSON.stringify(attributes),
|
|
742
|
+
}).catch((err) => {
|
|
743
|
+
api.logger.warn(
|
|
744
|
+
`[sage-capture] prompt capture failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
745
|
+
);
|
|
746
|
+
});
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
const captureResponseFromEvent = (hookName: string, event: any): void => {
|
|
750
|
+
if (!captureHooksEnabled) return;
|
|
751
|
+
|
|
752
|
+
const response = normalizePrompt(extractEventResponse(event), { maxBytes: maxPromptBytes });
|
|
753
|
+
if (!response) return;
|
|
754
|
+
|
|
755
|
+
const sessionId = extractEventSessionId(event) || captureState.sessionId;
|
|
756
|
+
const model = extractEventModel(event) || captureState.model;
|
|
757
|
+
const provider = extractEventProvider(event) || captureState.provider;
|
|
758
|
+
const tokensInput = extractEventTokenCount(event, "input");
|
|
759
|
+
const tokensOutput = extractEventTokenCount(event, "output");
|
|
760
|
+
|
|
761
|
+
const attributes = {
|
|
762
|
+
openclaw: {
|
|
763
|
+
hook: hookName,
|
|
764
|
+
sessionId: sessionId || undefined,
|
|
765
|
+
},
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
void runCaptureHook("response", {
|
|
769
|
+
SAGE_SOURCE: "openclaw",
|
|
770
|
+
OPENCLAW: "1",
|
|
771
|
+
SAGE_RESPONSE: response,
|
|
772
|
+
LAST_RESPONSE: response,
|
|
773
|
+
TOKENS_INPUT: tokensInput,
|
|
774
|
+
TOKENS_OUTPUT: tokensOutput,
|
|
775
|
+
SAGE_SESSION_ID: sessionId || "",
|
|
776
|
+
SAGE_MODEL: model || "",
|
|
777
|
+
SAGE_PROVIDER: provider || "",
|
|
778
|
+
SAGE_CAPTURE_ATTRIBUTES_JSON: JSON.stringify(attributes),
|
|
779
|
+
}).catch((err) => {
|
|
780
|
+
api.logger.warn(
|
|
781
|
+
`[sage-capture] response capture failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
782
|
+
);
|
|
783
|
+
});
|
|
784
|
+
};
|
|
785
|
+
|
|
492
786
|
// Main sage MCP bridge
|
|
493
787
|
sageBridge = new McpBridge(sageBinary, ["mcp", "start"], sageEnv, {
|
|
494
788
|
clientVersion: PKG_VERSION,
|
|
@@ -507,15 +801,14 @@ const plugin = {
|
|
|
507
801
|
ctx.logger.info("Sage MCP bridge ready");
|
|
508
802
|
|
|
509
803
|
const tools = await sageBridge!.listTools();
|
|
510
|
-
ctx.logger.info(`Discovered ${tools.length}
|
|
804
|
+
ctx.logger.info(`Discovered ${tools.length} Sage MCP tools`);
|
|
511
805
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
}
|
|
806
|
+
registerCodeModeTools(api, {
|
|
807
|
+
injectionGuardEnabled,
|
|
808
|
+
injectionGuardScanGetPrompt,
|
|
809
|
+
injectionGuardMode,
|
|
810
|
+
scanText,
|
|
811
|
+
});
|
|
519
812
|
|
|
520
813
|
// Register sage_status meta-tool for bridge health reporting
|
|
521
814
|
registerStatusTool(api, tools.length);
|
|
@@ -524,51 +817,10 @@ const plugin = {
|
|
|
524
817
|
`Failed to start sage MCP bridge: ${err instanceof Error ? err.message : String(err)}`,
|
|
525
818
|
);
|
|
526
819
|
}
|
|
527
|
-
|
|
528
|
-
// Load and start external servers
|
|
529
|
-
const customServers = loadCustomServers();
|
|
530
|
-
ctx.logger.info(`Found ${customServers.length} custom external servers`);
|
|
531
|
-
|
|
532
|
-
for (const server of customServers) {
|
|
533
|
-
try {
|
|
534
|
-
ctx.logger.info(`Starting external server: ${server.name} (${server.id})`);
|
|
535
|
-
|
|
536
|
-
const { command, args } = getServerCommand(server);
|
|
537
|
-
const bridge = new McpBridge(command, args, server.env);
|
|
538
|
-
|
|
539
|
-
bridge.on("log", (line: string) => ctx.logger.info(`[${server.id}] ${line}`));
|
|
540
|
-
bridge.on("error", (err: Error) => ctx.logger.error(`[${server.id}] ${err.message}`));
|
|
541
|
-
|
|
542
|
-
await bridge.start();
|
|
543
|
-
externalBridges.set(server.id, bridge);
|
|
544
|
-
|
|
545
|
-
const tools = await bridge.listTools();
|
|
546
|
-
ctx.logger.info(`[${server.id}] Discovered ${tools.length} tools`);
|
|
547
|
-
|
|
548
|
-
for (const tool of tools) {
|
|
549
|
-
registerMcpTool(api, server.id.replace(/-/g, "_"), bridge, tool, {
|
|
550
|
-
injectionGuardScanGetPrompt: false,
|
|
551
|
-
injectionGuardMode: "warn",
|
|
552
|
-
scanText,
|
|
553
|
-
});
|
|
554
|
-
}
|
|
555
|
-
} catch (err) {
|
|
556
|
-
ctx.logger.error(
|
|
557
|
-
`Failed to start ${server.name}: ${err instanceof Error ? err.message : String(err)}`,
|
|
558
|
-
);
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
820
|
},
|
|
562
821
|
stop: async (ctx) => {
|
|
563
822
|
ctx.logger.info("Stopping Sage MCP bridges...");
|
|
564
823
|
|
|
565
|
-
// Stop external bridges
|
|
566
|
-
for (const [id, bridge] of externalBridges) {
|
|
567
|
-
ctx.logger.info(`Stopping ${id}...`);
|
|
568
|
-
await bridge.stop();
|
|
569
|
-
}
|
|
570
|
-
externalBridges.clear();
|
|
571
|
-
|
|
572
824
|
// Stop main sage bridge
|
|
573
825
|
await sageBridge?.stop();
|
|
574
826
|
},
|
|
@@ -577,9 +829,9 @@ const plugin = {
|
|
|
577
829
|
// Auto-inject context and suggestions at agent start.
|
|
578
830
|
// This uses OpenClaw's plugin hook API (not internal hooks).
|
|
579
831
|
api.on("before_agent_start", async (event: any) => {
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
});
|
|
832
|
+
capturePromptFromEvent("before_agent_start", event);
|
|
833
|
+
|
|
834
|
+
const prompt = normalizePrompt(extractEventPrompt(event), { maxBytes: maxPromptBytes });
|
|
583
835
|
let guardNotice = "";
|
|
584
836
|
if (injectionGuardScanAgentPrompt && prompt) {
|
|
585
837
|
const scan = await scanText(prompt);
|
|
@@ -594,20 +846,79 @@ const plugin = {
|
|
|
594
846
|
}
|
|
595
847
|
}
|
|
596
848
|
|
|
849
|
+
// Read locally-synced soul document (written by `sync_library_stream` tool)
|
|
850
|
+
let soulContent = "";
|
|
851
|
+
if (soulStreamDao) {
|
|
852
|
+
const xdgData = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share");
|
|
853
|
+
const soulPath = join(
|
|
854
|
+
xdgData,
|
|
855
|
+
"sage",
|
|
856
|
+
"souls",
|
|
857
|
+
`${soulStreamDao}-${soulStreamLibraryId}.md`,
|
|
858
|
+
);
|
|
859
|
+
try {
|
|
860
|
+
if (existsSync(soulPath)) {
|
|
861
|
+
soulContent = readFileSync(soulPath, "utf8").trim();
|
|
862
|
+
}
|
|
863
|
+
} catch {
|
|
864
|
+
// Soul file unreadable — skip silently
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
597
868
|
if (!prompt || prompt.length < minPromptLen) {
|
|
598
869
|
const parts: string[] = [];
|
|
599
|
-
if (
|
|
870
|
+
if (soulContent) parts.push(soulContent);
|
|
871
|
+
if (autoInject) parts.push(SAGE_FULL_CONTEXT);
|
|
600
872
|
if (guardNotice) parts.push(guardNotice);
|
|
601
873
|
return parts.length ? { prependContext: parts.join("\n\n") } : undefined;
|
|
602
874
|
}
|
|
603
875
|
|
|
604
876
|
let suggestBlock = "";
|
|
605
|
-
|
|
877
|
+
const isHeartbeat = isHeartbeatPrompt(prompt);
|
|
878
|
+
|
|
879
|
+
if (isHeartbeat && heartbeatContextSuggest && sageBridge?.isReady()) {
|
|
880
|
+
const now = Date.now();
|
|
881
|
+
const cooldownElapsed =
|
|
882
|
+
now - heartbeatSuggestState.lastFullAnalysisTs >= heartbeatSuggestCooldownMs;
|
|
883
|
+
|
|
884
|
+
if (cooldownElapsed) {
|
|
885
|
+
api.logger.info("[heartbeat-context] Running full context-aware skill analysis");
|
|
886
|
+
try {
|
|
887
|
+
const context = await gatherHeartbeatContext(
|
|
888
|
+
sageBridge,
|
|
889
|
+
api.logger,
|
|
890
|
+
heartbeatContextMaxChars,
|
|
891
|
+
);
|
|
892
|
+
if (context) {
|
|
893
|
+
suggestBlock = await searchSkillsForContext(
|
|
894
|
+
sageBridge,
|
|
895
|
+
context,
|
|
896
|
+
suggestLimit,
|
|
897
|
+
api.logger,
|
|
898
|
+
);
|
|
899
|
+
heartbeatSuggestState.lastFullAnalysisTs = now;
|
|
900
|
+
heartbeatSuggestState.lastSuggestions = suggestBlock;
|
|
901
|
+
}
|
|
902
|
+
} catch (err) {
|
|
903
|
+
api.logger.warn(
|
|
904
|
+
`[heartbeat-context] Full analysis failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
905
|
+
);
|
|
906
|
+
}
|
|
907
|
+
} else {
|
|
908
|
+
suggestBlock = heartbeatSuggestState.lastSuggestions;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
if (!suggestBlock && autoSuggest && sageBridge?.isReady()) {
|
|
606
913
|
try {
|
|
607
|
-
const raw = await
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
914
|
+
const raw = await sageSearch({
|
|
915
|
+
domain: "skills",
|
|
916
|
+
action: "search",
|
|
917
|
+
params: {
|
|
918
|
+
query: prompt,
|
|
919
|
+
source: "all",
|
|
920
|
+
limit: Math.max(20, suggestLimit),
|
|
921
|
+
},
|
|
611
922
|
});
|
|
612
923
|
const json = extractJsonFromMcpResult(raw) as any;
|
|
613
924
|
const results = Array.isArray(json?.results) ? (json.results as SkillSearchResult[]) : [];
|
|
@@ -618,13 +929,26 @@ const plugin = {
|
|
|
618
929
|
}
|
|
619
930
|
|
|
620
931
|
const parts: string[] = [];
|
|
621
|
-
if (
|
|
932
|
+
if (soulContent) parts.push(soulContent);
|
|
933
|
+
if (autoInject) parts.push(SAGE_FULL_CONTEXT);
|
|
622
934
|
if (guardNotice) parts.push(guardNotice);
|
|
623
935
|
if (suggestBlock) parts.push(suggestBlock);
|
|
624
936
|
|
|
625
937
|
if (!parts.length) return undefined;
|
|
626
938
|
return { prependContext: parts.join("\n\n") };
|
|
627
939
|
});
|
|
940
|
+
|
|
941
|
+
api.on("after_agent_response", async (event: any) => {
|
|
942
|
+
captureResponseFromEvent("after_agent_response", event);
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
// Legacy OpenClaw hook names observed in older runtime builds.
|
|
946
|
+
api.on("message_received", async (event: any) => {
|
|
947
|
+
capturePromptFromEvent("message_received", event);
|
|
948
|
+
});
|
|
949
|
+
api.on("agent_end", async (event: any) => {
|
|
950
|
+
captureResponseFromEvent("agent_end", event);
|
|
951
|
+
});
|
|
628
952
|
},
|
|
629
953
|
};
|
|
630
954
|
|
|
@@ -634,11 +958,18 @@ function enrichErrorMessage(err: Error, toolName: string): string {
|
|
|
634
958
|
|
|
635
959
|
// Wallet not configured
|
|
636
960
|
if (/wallet|signer|no.*account|not.*connected/i.test(msg)) {
|
|
637
|
-
return `${msg}\n\nHint: Run \`sage wallet connect\` to configure a wallet, or set KEYSTORE_PASSWORD for automated flows.`;
|
|
961
|
+
return `${msg}\n\nHint: Run \`sage wallet connect privy\` (or \`sage wallet connect\`) to configure a wallet, or set KEYSTORE_PASSWORD for automated flows.`;
|
|
962
|
+
}
|
|
963
|
+
// Privy session/auth issues
|
|
964
|
+
if (/privy|session.*expired|re-authenticate|wallet session expired/i.test(msg)) {
|
|
965
|
+
return `${msg}\n\nHint: Reconnect with login-code flow:\n \`sage wallet connect privy --force --device-code\`\nThen verify:\n \`sage wallet current\`\n \`sage daemon status\``;
|
|
638
966
|
}
|
|
639
967
|
// Auth / token issues
|
|
640
968
|
if (/auth|unauthorized|403|401|token.*expired|challenge/i.test(msg)) {
|
|
641
|
-
|
|
969
|
+
if (/ipfs|upload token|pin|credits/i.test(msg) || /ipfs|upload|pin|credit/i.test(toolName)) {
|
|
970
|
+
return `${msg}\n\nHint: Run \`sage config ipfs setup\` to refresh authentication, or check SAGE_IPFS_UPLOAD_TOKEN.`;
|
|
971
|
+
}
|
|
972
|
+
return `${msg}\n\nHint: Reconnect wallet auth with:\n \`sage wallet connect privy --force --device-code\``;
|
|
642
973
|
}
|
|
643
974
|
// Network / RPC failures
|
|
644
975
|
if (/rpc|network|timeout|ECONNREFUSED|ENOTFOUND|fetch.*failed/i.test(msg)) {
|
|
@@ -650,7 +981,7 @@ function enrichErrorMessage(err: Error, toolName: string): string {
|
|
|
650
981
|
}
|
|
651
982
|
// Credits
|
|
652
983
|
if (/credits|insufficient.*balance|IPFS.*balance/i.test(msg)) {
|
|
653
|
-
return `${msg}\n\nHint: Run \`sage ipfs faucet\` (testnet) or purchase credits via \`sage wallet buy\`.`;
|
|
984
|
+
return `${msg}\n\nHint: Run \`sage config ipfs faucet\` (testnet; legacy: \`sage ipfs faucet\`) or purchase credits via \`sage wallet buy\`.`;
|
|
654
985
|
}
|
|
655
986
|
|
|
656
987
|
return msg;
|
|
@@ -661,19 +992,22 @@ function registerStatusTool(api: PluginApi, sageToolCount: number) {
|
|
|
661
992
|
{
|
|
662
993
|
name: "sage_status",
|
|
663
994
|
label: "Sage: status",
|
|
664
|
-
description:
|
|
995
|
+
description:
|
|
996
|
+
"Check Sage plugin health: bridge connection, tool count, network profile, and wallet status",
|
|
665
997
|
parameters: Type.Object({}),
|
|
666
998
|
execute: async () => {
|
|
667
999
|
const bridgeReady = sageBridge?.isReady() ?? false;
|
|
668
|
-
const externalCount = externalBridges.size;
|
|
669
|
-
const externalIds = Array.from(externalBridges.keys());
|
|
670
1000
|
|
|
671
1001
|
// Try to get wallet + network info from sage
|
|
672
1002
|
let walletInfo = "unknown";
|
|
673
1003
|
let networkInfo = "unknown";
|
|
674
1004
|
if (bridgeReady && sageBridge) {
|
|
675
1005
|
try {
|
|
676
|
-
const ctx = await
|
|
1006
|
+
const ctx = await sageSearch({
|
|
1007
|
+
domain: "meta",
|
|
1008
|
+
action: "get_project_context",
|
|
1009
|
+
params: {},
|
|
1010
|
+
});
|
|
677
1011
|
const json = extractJsonFromMcpResult(ctx) as any;
|
|
678
1012
|
if (json?.wallet?.address) walletInfo = json.wallet.address;
|
|
679
1013
|
if (json?.network) networkInfo = json.network;
|
|
@@ -686,8 +1020,6 @@ function registerStatusTool(api: PluginApi, sageToolCount: number) {
|
|
|
686
1020
|
pluginVersion: PKG_VERSION,
|
|
687
1021
|
bridgeConnected: bridgeReady,
|
|
688
1022
|
sageToolCount,
|
|
689
|
-
externalServerCount: externalCount,
|
|
690
|
-
externalServers: externalIds,
|
|
691
1023
|
wallet: walletInfo,
|
|
692
1024
|
network: networkInfo,
|
|
693
1025
|
profile: process.env.SAGE_PROFILE || "default",
|
|
@@ -703,44 +1035,93 @@ function registerStatusTool(api: PluginApi, sageToolCount: number) {
|
|
|
703
1035
|
);
|
|
704
1036
|
}
|
|
705
1037
|
|
|
706
|
-
function
|
|
1038
|
+
function registerCodeModeTools(
|
|
707
1039
|
api: PluginApi,
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
tool: McpToolDef,
|
|
711
|
-
opts?: {
|
|
1040
|
+
opts: {
|
|
1041
|
+
injectionGuardEnabled: boolean;
|
|
712
1042
|
injectionGuardScanGetPrompt: boolean;
|
|
713
1043
|
injectionGuardMode: "warn" | "block";
|
|
714
1044
|
scanText: (text: string) => Promise<SecurityScanResult | null>;
|
|
715
1045
|
},
|
|
716
1046
|
) {
|
|
717
|
-
|
|
718
|
-
|
|
1047
|
+
api.registerTool(
|
|
1048
|
+
{
|
|
1049
|
+
name: "sage_search",
|
|
1050
|
+
label: "Sage: search",
|
|
1051
|
+
description: "Sage code-mode search/discovery (domain/action routing)",
|
|
1052
|
+
parameters: Type.Object({
|
|
1053
|
+
domain: SageDomain,
|
|
1054
|
+
action: Type.String(),
|
|
1055
|
+
params: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
1056
|
+
}),
|
|
1057
|
+
execute: async (_toolCallId: string, params: Record<string, unknown>) => {
|
|
1058
|
+
try {
|
|
1059
|
+
const domain = String(params.domain ?? "");
|
|
1060
|
+
const action = String(params.action ?? "");
|
|
1061
|
+
const p =
|
|
1062
|
+
params.params && typeof params.params === "object"
|
|
1063
|
+
? (params.params as Record<string, unknown>)
|
|
1064
|
+
: {};
|
|
1065
|
+
|
|
1066
|
+
if (domain === "external" && !["list_servers", "search"].includes(action)) {
|
|
1067
|
+
return toToolResult({
|
|
1068
|
+
error: "For external domain, sage_search only supports actions: list_servers, search",
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
719
1071
|
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
1072
|
+
const result = await sageSearch({ domain, action, params: p });
|
|
1073
|
+
return toToolResult(result);
|
|
1074
|
+
} catch (err) {
|
|
1075
|
+
const enriched = enrichErrorMessage(
|
|
1076
|
+
err instanceof Error ? err : new Error(String(err)),
|
|
1077
|
+
"sage_search",
|
|
1078
|
+
);
|
|
1079
|
+
return toToolResult({ error: enriched });
|
|
1080
|
+
}
|
|
1081
|
+
},
|
|
1082
|
+
},
|
|
1083
|
+
{ name: "sage_search", optional: true },
|
|
1084
|
+
);
|
|
727
1085
|
|
|
728
1086
|
api.registerTool(
|
|
729
1087
|
{
|
|
730
|
-
name,
|
|
731
|
-
label,
|
|
732
|
-
description:
|
|
733
|
-
parameters:
|
|
1088
|
+
name: "sage_execute",
|
|
1089
|
+
label: "Sage: execute",
|
|
1090
|
+
description: "Sage code-mode execute/mutations (domain/action routing)",
|
|
1091
|
+
parameters: Type.Object({
|
|
1092
|
+
domain: SageDomain,
|
|
1093
|
+
action: Type.String(),
|
|
1094
|
+
params: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
1095
|
+
}),
|
|
734
1096
|
execute: async (_toolCallId: string, params: Record<string, unknown>) => {
|
|
735
|
-
if (!bridge.isReady()) {
|
|
736
|
-
return toToolResult({
|
|
737
|
-
error: "MCP bridge not connected. The sage subprocess may have crashed — try restarting the plugin.",
|
|
738
|
-
});
|
|
739
|
-
}
|
|
740
1097
|
try {
|
|
741
|
-
const
|
|
1098
|
+
const domain = String(params.domain ?? "");
|
|
1099
|
+
const action = String(params.action ?? "");
|
|
1100
|
+
const p =
|
|
1101
|
+
params.params && typeof params.params === "object"
|
|
1102
|
+
? (params.params as Record<string, unknown>)
|
|
1103
|
+
: {};
|
|
1104
|
+
|
|
1105
|
+
if (opts.injectionGuardEnabled) {
|
|
1106
|
+
const scan = await opts.scanText(JSON.stringify({ domain, action, params: p }));
|
|
1107
|
+
if (scan?.shouldBlock) {
|
|
1108
|
+
const summary = formatSecuritySummary(scan);
|
|
1109
|
+
if (opts.injectionGuardMode === "block") {
|
|
1110
|
+
return toToolResult({ error: `Blocked by injection guard: ${summary}` });
|
|
1111
|
+
}
|
|
1112
|
+
api.logger.warn(`[injection-guard] warn: ${summary}`);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
if (domain === "external" && !["execute", "call"].includes(action)) {
|
|
1117
|
+
return toToolResult({
|
|
1118
|
+
error: "For external domain, sage_execute only supports actions: execute, call",
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
const result = await sageExecute({ domain, action, params: p });
|
|
742
1123
|
|
|
743
|
-
if (opts
|
|
1124
|
+
if (opts.injectionGuardScanGetPrompt && domain === "prompts" && action === "get") {
|
|
744
1125
|
const json = extractJsonFromMcpResult(result) as any;
|
|
745
1126
|
const content =
|
|
746
1127
|
typeof json?.prompt?.content === "string"
|
|
@@ -759,12 +1140,8 @@ function registerMcpTool(
|
|
|
759
1140
|
);
|
|
760
1141
|
}
|
|
761
1142
|
|
|
762
|
-
// Warn mode: attach a compact summary to the JSON output.
|
|
763
1143
|
if (json && typeof json === "object") {
|
|
764
|
-
json.security = {
|
|
765
|
-
shouldBlock: true,
|
|
766
|
-
summary,
|
|
767
|
-
};
|
|
1144
|
+
json.security = { shouldBlock: true, summary };
|
|
768
1145
|
return {
|
|
769
1146
|
content: [{ type: "text" as const, text: JSON.stringify(json) }],
|
|
770
1147
|
details: result,
|
|
@@ -778,13 +1155,13 @@ function registerMcpTool(
|
|
|
778
1155
|
} catch (err) {
|
|
779
1156
|
const enriched = enrichErrorMessage(
|
|
780
1157
|
err instanceof Error ? err : new Error(String(err)),
|
|
781
|
-
|
|
1158
|
+
"sage_execute",
|
|
782
1159
|
);
|
|
783
1160
|
return toToolResult({ error: enriched });
|
|
784
1161
|
}
|
|
785
1162
|
},
|
|
786
1163
|
},
|
|
787
|
-
{ name, optional: true },
|
|
1164
|
+
{ name: "sage_execute", optional: true },
|
|
788
1165
|
);
|
|
789
1166
|
}
|
|
790
1167
|
|
|
@@ -792,7 +1169,7 @@ export default plugin;
|
|
|
792
1169
|
|
|
793
1170
|
export const __test = {
|
|
794
1171
|
PKG_VERSION,
|
|
795
|
-
SAGE_CONTEXT,
|
|
1172
|
+
SAGE_CONTEXT: SAGE_FULL_CONTEXT,
|
|
796
1173
|
normalizePrompt,
|
|
797
1174
|
extractJsonFromMcpResult,
|
|
798
1175
|
formatSkillSuggestions,
|