@sage-protocol/openclaw-sage 0.1.5 → 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/src/index.ts CHANGED
@@ -1,37 +1,49 @@
1
- import { Type } from "@sinclair/typebox";
2
- import { readFileSync, existsSync } from "node:fs";
1
+ import { Type, type TSchema } from "@sinclair/typebox";
2
+ import { readFileSync, existsSync, readdirSync } from "node:fs";
3
+ import { spawn } from "node:child_process";
3
4
  import { homedir } from "node:os";
4
- import { join } from "node:path";
5
+ import { join, resolve, dirname } from "node:path";
5
6
  import { createHash } from "node:crypto";
6
- import TOML from "@iarna/toml";
7
+ import { fileURLToPath } from "node:url";
7
8
 
8
- import { McpBridge, type McpToolDef } from "./mcp-bridge.js";
9
+ import { McpBridge } from "./mcp-bridge.js";
9
10
 
10
- const SAGE_CONTEXT = `## Sage MCP Tools Available
11
+ // Read version from package.json at module load time
12
+ const __dirname_compat = dirname(fileURLToPath(import.meta.url));
13
+ const PKG_VERSION: string = (() => {
14
+ try {
15
+ const pkg = JSON.parse(readFileSync(resolve(__dirname_compat, "..", "package.json"), "utf8"));
16
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
17
+ } catch {
18
+ return "0.0.0";
19
+ }
20
+ })();
21
+
22
+ const SAGE_CONTEXT = `## Sage (Code Mode)
11
23
 
12
- You have access to Sage MCP tools for prompts, skills, and knowledge discovery.
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.
13
28
 
14
- ### Prompt Discovery
15
- - \`search_prompts\` - Hybrid keyword + semantic search for prompts
16
- - \`list_prompts\` - Browse prompts by source (local/onchain)
17
- - \`get_prompt\` - Get full prompt content by key
18
- - \`builder_recommend\` - AI-powered prompt suggestions based on intent
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.
19
32
 
20
- ### Skills
21
- - \`search_skills\` / \`list_skills\` - Find available skills
22
- - \`get_skill\` - Get skill details and content
23
- - \`use_skill\` - Activate a skill (auto-provisions required MCP servers)
33
+ Domains: prompts, skills, builder, governance, chat, social, rlm, library_sync, security, meta, help, external
24
34
 
25
- ### External Tools (via Hub)
26
- - \`hub_list_servers\` - List available MCP servers (memory, github, brave, etc.)
27
- - \`hub_start_server\` - Start an MCP server to gain access to its tools
28
- - \`hub_status\` - Check which servers are currently running
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: {...} } }`;
29
43
 
30
- ### Best Practices
31
- 1. **Search before implementing** - Use \`search_prompts\` or \`builder_recommend\` to find existing solutions
32
- 2. **Use skills for complex tasks** - Skills bundle prompts + MCP servers for specific workflows
33
- 3. **Start additional servers as needed** - Use \`hub_start_server\` for memory, github, brave search, etc.
34
- 4. **Check skill requirements** - Skills may require specific MCP servers; \`use_skill\` auto-provisions them`;
44
+ const SAGE_STATUS_CONTEXT = `\n\nPlugin meta-tool:\n- \`sage_status\` - show bridge health + wallet/network context`;
45
+
46
+ const SAGE_FULL_CONTEXT = `${SAGE_CONTEXT}${SAGE_STATUS_CONTEXT}`;
35
47
 
36
48
  /**
37
49
  * Minimal type stubs for OpenClaw plugin API.
@@ -119,7 +131,11 @@ function sha256Hex(s: string): string {
119
131
 
120
132
  type SecurityScanResult = {
121
133
  shouldBlock?: boolean;
122
- report?: { level?: string; issue_count?: number; issues?: Array<{ rule_id?: string; category?: string; severity?: string }> };
134
+ report?: {
135
+ level?: string;
136
+ issue_count?: number;
137
+ issues?: Array<{ rule_id?: string; category?: string; severity?: string }>;
138
+ };
123
139
  promptGuard?: { finding?: { detected?: boolean; type?: string; confidence?: number } };
124
140
  };
125
141
 
@@ -163,28 +179,279 @@ function formatSkillSuggestions(results: SkillSearchResult[], limit: number): st
163
179
  for (const r of items) {
164
180
  const key = r.key!.trim();
165
181
  const desc = typeof r.description === "string" ? r.description.trim() : "";
166
- const origin = typeof r.library === "string" && r.library.trim() ? ` (from ${r.library.trim()})` : "";
167
- const servers = Array.isArray(r.mcpServers) && r.mcpServers.length ? ` requires: ${r.mcpServers.join(", ")}` : "";
168
- lines.push(`- \`use_skill\` \`${key}\`${origin}${desc ? `: ${desc}` : ""}${servers}`);
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
+ );
169
191
  }
170
192
  return lines.join("\n");
171
193
  }
172
194
 
173
- /** Custom server configuration from mcp-servers.toml */
174
- type CustomServerConfig = {
175
- id: string;
176
- name: string;
177
- description?: string;
178
- enabled: boolean;
179
- source: {
180
- type: "npx" | "node" | "binary";
181
- package?: string;
182
- path?: string;
183
- };
184
- extra_args?: string[];
185
- env?: Record<string, string>;
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>;
186
398
  };
187
399
 
400
+ /**
401
+ * Convert a single MCP JSON Schema property into a TypeBox type.
402
+ * Handles nested objects, typed arrays, and enums.
403
+ */
404
+ function jsonSchemaToTypebox(prop: Record<string, unknown>): TSchema {
405
+ const desc = typeof prop.description === "string" ? prop.description : undefined;
406
+ const opts: Record<string, unknown> = {};
407
+ if (desc) opts.description = desc;
408
+
409
+ // Enum support: string enums become Type.Union of Type.Literal
410
+ if (Array.isArray(prop.enum) && prop.enum.length > 0) {
411
+ const literals = prop.enum
412
+ .filter((v): v is string | number | boolean =>
413
+ ["string", "number", "boolean"].includes(typeof v),
414
+ )
415
+ .map((v) => Type.Literal(v));
416
+ if (literals.length > 0) {
417
+ return literals.length === 1 ? literals[0] : Type.Union(literals, opts);
418
+ }
419
+ }
420
+
421
+ switch (prop.type) {
422
+ case "number":
423
+ case "integer":
424
+ return Type.Number(opts);
425
+ case "boolean":
426
+ return Type.Boolean(opts);
427
+ case "array": {
428
+ // Typed array items
429
+ const items = prop.items as Record<string, unknown> | undefined;
430
+ const itemType =
431
+ items && typeof items === "object" ? jsonSchemaToTypebox(items) : Type.Unknown();
432
+ return Type.Array(itemType, opts);
433
+ }
434
+ case "object": {
435
+ // Nested object with known properties
436
+ const nested = prop.properties as Record<string, Record<string, unknown>> | undefined;
437
+ if (nested && typeof nested === "object" && Object.keys(nested).length > 0) {
438
+ const nestedRequired = new Set(
439
+ Array.isArray(prop.required) ? (prop.required as string[]) : [],
440
+ );
441
+ const nestedFields: Record<string, TSchema> = {};
442
+ for (const [k, v] of Object.entries(nested)) {
443
+ const field = jsonSchemaToTypebox(v);
444
+ nestedFields[k] = nestedRequired.has(k) ? field : Type.Optional(field);
445
+ }
446
+ return Type.Object(nestedFields, { ...opts, additionalProperties: true });
447
+ }
448
+ return Type.Record(Type.String(), Type.Unknown(), opts);
449
+ }
450
+ default:
451
+ return Type.String(opts);
452
+ }
453
+ }
454
+
188
455
  /**
189
456
  * Convert an MCP JSON Schema inputSchema into a TypeBox object schema
190
457
  * that OpenClaw's tool system accepts.
@@ -199,35 +466,14 @@ function mcpSchemaToTypebox(inputSchema?: Record<string, unknown>) {
199
466
  Array.isArray(inputSchema.required) ? (inputSchema.required as string[]) : [],
200
467
  );
201
468
 
202
- const fields: Record<string, unknown> = {};
469
+ const fields: Record<string, TSchema> = {};
203
470
 
204
471
  for (const [key, prop] of Object.entries(properties)) {
205
- const desc = typeof prop.description === "string" ? prop.description : undefined;
206
- const opts = desc ? { description: desc } : {};
207
-
208
- let field: unknown;
209
- switch (prop.type) {
210
- case "number":
211
- case "integer":
212
- field = Type.Number(opts);
213
- break;
214
- case "boolean":
215
- field = Type.Boolean(opts);
216
- break;
217
- case "array":
218
- field = Type.Array(Type.Unknown(), opts);
219
- break;
220
- case "object":
221
- field = Type.Record(Type.String(), Type.Unknown(), opts);
222
- break;
223
- default:
224
- field = Type.String(opts);
225
- }
226
-
227
- fields[key] = required.has(key) ? field : Type.Optional(field as any);
472
+ const field = jsonSchemaToTypebox(prop);
473
+ fields[key] = required.has(key) ? field : Type.Optional(field);
228
474
  }
229
475
 
230
- return Type.Object(fields as any, { additionalProperties: true });
476
+ return Type.Object(fields, { additionalProperties: true });
231
477
  }
232
478
 
233
479
  function toToolResult(mcpResult: unknown) {
@@ -250,94 +496,53 @@ function toToolResult(mcpResult: unknown) {
250
496
  /**
251
497
  * Load custom server configurations from ~/.config/sage/mcp-servers.toml
252
498
  */
253
- function loadCustomServers(): CustomServerConfig[] {
254
- const configPath = join(homedir(), ".config", "sage", "mcp-servers.toml");
255
-
256
- if (!existsSync(configPath)) {
257
- return [];
258
- }
259
-
260
- try {
261
- const content = readFileSync(configPath, "utf8");
262
- const config = TOML.parse(content) as {
263
- custom?: Record<string, {
264
- id: string;
265
- name: string;
266
- description?: string;
267
- enabled: boolean;
268
- source: { type: string; package?: string; path?: string };
269
- extra_args?: string[];
270
- env?: Record<string, string>;
271
- }>;
272
- };
273
-
274
- if (!config.custom) {
275
- return [];
276
- }
277
-
278
- return Object.values(config.custom)
279
- .filter((s) => s.enabled)
280
- .map((s) => ({
281
- id: s.id,
282
- name: s.name,
283
- description: s.description,
284
- enabled: s.enabled,
285
- source: {
286
- type: s.source.type as "npx" | "node" | "binary",
287
- package: s.source.package,
288
- path: s.source.path,
289
- },
290
- extra_args: s.extra_args,
291
- env: s.env,
292
- }));
293
- } catch (err) {
294
- console.error(`Failed to parse mcp-servers.toml: ${err}`);
295
- 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
+ );
296
504
  }
505
+ return sageBridge.callTool("sage_search", {
506
+ domain: req.domain,
507
+ action: req.action,
508
+ params: req.params ?? {},
509
+ });
297
510
  }
298
511
 
299
- /**
300
- * Create command and args for spawning an external server
301
- */
302
- function getServerCommand(server: CustomServerConfig): { command: string; args: string[] } {
303
- switch (server.source.type) {
304
- case "npx":
305
- return {
306
- command: "npx",
307
- args: ["-y", server.source.package!, ...(server.extra_args || [])],
308
- };
309
- case "node":
310
- return {
311
- command: "node",
312
- args: [server.source.path!, ...(server.extra_args || [])],
313
- };
314
- case "binary":
315
- return {
316
- command: server.source.path!,
317
- args: server.extra_args || [],
318
- };
319
- default:
320
- 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
+ );
321
517
  }
518
+ return sageBridge.callTool("sage_execute", {
519
+ domain: req.domain,
520
+ action: req.action,
521
+ params: req.params ?? {},
522
+ });
322
523
  }
323
524
 
324
525
  // ── Plugin Definition ────────────────────────────────────────────────────────
325
526
 
326
527
  let sageBridge: McpBridge | null = null;
327
- const externalBridges: Map<string, McpBridge> = new Map();
328
528
 
329
529
  const plugin = {
330
530
  id: "openclaw-sage",
331
531
  name: "Sage Protocol",
332
- version: "0.2.0",
532
+ version: PKG_VERSION,
333
533
  description:
334
- "Sage MCP tools for prompt libraries, skills, governance, and on-chain operations (including external servers)",
534
+ "Sage MCP tools for prompts, skills, governance, and external tool routing after hub-managed servers are started",
335
535
 
336
536
  register(api: PluginApi) {
337
537
  const pluginCfg = api.pluginConfig ?? {};
338
- const sageBinary = typeof pluginCfg.sageBinary === "string" && pluginCfg.sageBinary.trim()
339
- ? pluginCfg.sageBinary.trim()
340
- : "sage";
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;
341
546
 
342
547
  const autoInject = pluginCfg.autoInjectContext !== false;
343
548
  const autoSuggest = pluginCfg.autoSuggestSkills !== false;
@@ -345,6 +550,17 @@ const plugin = {
345
550
  const minPromptLen = clampInt(pluginCfg.minPromptLen, 12, 0, 500);
346
551
  const maxPromptBytes = clampInt(pluginCfg.maxPromptBytes, 16_384, 512, 65_536);
347
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
+
348
564
  // Injection guard (opt-in)
349
565
  const injectionGuardEnabled = pluginCfg.injectionGuardEnabled === true;
350
566
  const injectionGuardMode = pluginCfg.injectionGuardMode === "block" ? "block" : "warn";
@@ -354,9 +570,21 @@ const plugin = {
354
570
  const injectionGuardScanGetPrompt = injectionGuardEnabled
355
571
  ? pluginCfg.injectionGuardScanGetPrompt !== false
356
572
  : false;
357
- const injectionGuardUsePromptGuard = injectionGuardEnabled && pluginCfg.injectionGuardUsePromptGuard === true;
573
+ const injectionGuardUsePromptGuard =
574
+ injectionGuardEnabled && pluginCfg.injectionGuardUsePromptGuard === true;
358
575
  const injectionGuardMaxChars = clampInt(pluginCfg.injectionGuardMaxChars, 32_768, 256, 200_000);
359
- const injectionGuardIncludeEvidence = injectionGuardEnabled && pluginCfg.injectionGuardIncludeEvidence === true;
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";
360
588
 
361
589
  const scanCache = new Map<string, { ts: number; scan: SecurityScanResult }>();
362
590
  const SCAN_CACHE_LIMIT = 256;
@@ -373,12 +601,16 @@ const plugin = {
373
601
  if (cached && now - cached.ts < SCAN_CACHE_TTL_MS) return cached.scan;
374
602
 
375
603
  try {
376
- const raw = await sageBridge.callTool("security_scan_text", {
377
- text: trimmed,
378
- maxChars: injectionGuardMaxChars,
379
- maxEvidenceLen: 100,
380
- includeEvidence: injectionGuardIncludeEvidence,
381
- usePromptGuard: injectionGuardUsePromptGuard,
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
+ },
382
614
  });
383
615
  const json = extractJsonFromMcpResult(raw) as any;
384
616
  const scan: SecurityScanResult = (json && typeof json === "object" ? json : {}) as any;
@@ -395,13 +627,165 @@ const plugin = {
395
627
  }
396
628
  };
397
629
 
398
- // Main sage MCP bridge - pass HOME to ensure auth state is found
399
- sageBridge = new McpBridge(sageBinary, ["mcp", "start"], {
630
+ // Build env for sage subprocess pass through auth/wallet state and profile config
631
+ const sageEnv: Record<string, string> = {
400
632
  HOME: homedir(),
401
633
  PATH: process.env.PATH || "",
402
634
  USER: process.env.USER || "",
403
635
  XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME || join(homedir(), ".config"),
404
636
  XDG_DATA_HOME: process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"),
637
+ };
638
+ // Pass through Sage-specific env vars when set
639
+ const passthroughVars = [
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",
648
+ ];
649
+ for (const key of passthroughVars) {
650
+ if (process.env[key]) sageEnv[key] = process.env[key]!;
651
+ }
652
+ // Config-level profile override takes precedence
653
+ if (sageProfile) sageEnv.SAGE_PROFILE = sageProfile;
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
+
786
+ // Main sage MCP bridge
787
+ sageBridge = new McpBridge(sageBinary, ["mcp", "start"], sageEnv, {
788
+ clientVersion: PKG_VERSION,
405
789
  });
406
790
  sageBridge.on("log", (line: string) => api.logger.info(`[sage-mcp] ${line}`));
407
791
  sageBridge.on("error", (err: Error) => api.logger.error(`[sage-mcp] ${err.message}`));
@@ -417,65 +801,26 @@ const plugin = {
417
801
  ctx.logger.info("Sage MCP bridge ready");
418
802
 
419
803
  const tools = await sageBridge!.listTools();
420
- ctx.logger.info(`Discovered ${tools.length} internal MCP tools`);
804
+ ctx.logger.info(`Discovered ${tools.length} Sage MCP tools`);
421
805
 
422
- for (const tool of tools) {
423
- registerMcpTool(api, "sage", sageBridge!, tool, {
424
- injectionGuardScanGetPrompt,
425
- injectionGuardMode,
426
- scanText,
427
- });
428
- }
806
+ registerCodeModeTools(api, {
807
+ injectionGuardEnabled,
808
+ injectionGuardScanGetPrompt,
809
+ injectionGuardMode,
810
+ scanText,
811
+ });
812
+
813
+ // Register sage_status meta-tool for bridge health reporting
814
+ registerStatusTool(api, tools.length);
429
815
  } catch (err) {
430
816
  ctx.logger.error(
431
817
  `Failed to start sage MCP bridge: ${err instanceof Error ? err.message : String(err)}`,
432
818
  );
433
819
  }
434
-
435
- // Load and start external servers
436
- const customServers = loadCustomServers();
437
- ctx.logger.info(`Found ${customServers.length} custom external servers`);
438
-
439
- for (const server of customServers) {
440
- try {
441
- ctx.logger.info(`Starting external server: ${server.name} (${server.id})`);
442
-
443
- const { command, args } = getServerCommand(server);
444
- const bridge = new McpBridge(command, args, server.env);
445
-
446
- bridge.on("log", (line: string) => ctx.logger.info(`[${server.id}] ${line}`));
447
- bridge.on("error", (err: Error) => ctx.logger.error(`[${server.id}] ${err.message}`));
448
-
449
- await bridge.start();
450
- externalBridges.set(server.id, bridge);
451
-
452
- const tools = await bridge.listTools();
453
- ctx.logger.info(`[${server.id}] Discovered ${tools.length} tools`);
454
-
455
- for (const tool of tools) {
456
- registerMcpTool(api, server.id.replace(/-/g, "_"), bridge, tool, {
457
- injectionGuardScanGetPrompt: false,
458
- injectionGuardMode: "warn",
459
- scanText,
460
- });
461
- }
462
- } catch (err) {
463
- ctx.logger.error(
464
- `Failed to start ${server.name}: ${err instanceof Error ? err.message : String(err)}`,
465
- );
466
- }
467
- }
468
820
  },
469
821
  stop: async (ctx) => {
470
822
  ctx.logger.info("Stopping Sage MCP bridges...");
471
823
 
472
- // Stop external bridges
473
- for (const [id, bridge] of externalBridges) {
474
- ctx.logger.info(`Stopping ${id}...`);
475
- await bridge.stop();
476
- }
477
- externalBridges.clear();
478
-
479
824
  // Stop main sage bridge
480
825
  await sageBridge?.stop();
481
826
  },
@@ -484,9 +829,9 @@ const plugin = {
484
829
  // Auto-inject context and suggestions at agent start.
485
830
  // This uses OpenClaw's plugin hook API (not internal hooks).
486
831
  api.on("before_agent_start", async (event: any) => {
487
- const prompt = normalizePrompt(typeof event?.prompt === "string" ? event.prompt : "", {
488
- maxBytes: maxPromptBytes,
489
- });
832
+ capturePromptFromEvent("before_agent_start", event);
833
+
834
+ const prompt = normalizePrompt(extractEventPrompt(event), { maxBytes: maxPromptBytes });
490
835
  let guardNotice = "";
491
836
  if (injectionGuardScanAgentPrompt && prompt) {
492
837
  const scan = await scanText(prompt);
@@ -501,20 +846,79 @@ const plugin = {
501
846
  }
502
847
  }
503
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
+
504
868
  if (!prompt || prompt.length < minPromptLen) {
505
869
  const parts: string[] = [];
506
- if (autoInject) parts.push(SAGE_CONTEXT);
870
+ if (soulContent) parts.push(soulContent);
871
+ if (autoInject) parts.push(SAGE_FULL_CONTEXT);
507
872
  if (guardNotice) parts.push(guardNotice);
508
873
  return parts.length ? { prependContext: parts.join("\n\n") } : undefined;
509
874
  }
510
875
 
511
876
  let suggestBlock = "";
512
- if (autoSuggest && sageBridge) {
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()) {
513
913
  try {
514
- const raw = await sageBridge.callTool("search_skills", {
515
- query: prompt,
516
- source: "all",
517
- limit: Math.max(20, suggestLimit),
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
+ },
518
922
  });
519
923
  const json = extractJsonFromMcpResult(raw) as any;
520
924
  const results = Array.isArray(json?.results) ? (json.results as SkillSearchResult[]) : [];
@@ -525,41 +929,199 @@ const plugin = {
525
929
  }
526
930
 
527
931
  const parts: string[] = [];
528
- if (autoInject) parts.push(SAGE_CONTEXT);
932
+ if (soulContent) parts.push(soulContent);
933
+ if (autoInject) parts.push(SAGE_FULL_CONTEXT);
529
934
  if (guardNotice) parts.push(guardNotice);
530
935
  if (suggestBlock) parts.push(suggestBlock);
531
936
 
532
937
  if (!parts.length) return undefined;
533
938
  return { prependContext: parts.join("\n\n") };
534
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
+ });
535
952
  },
536
953
  };
537
954
 
538
- function registerMcpTool(
955
+ /** Map common error patterns to actionable hints */
956
+ function enrichErrorMessage(err: Error, toolName: string): string {
957
+ const msg = err.message;
958
+
959
+ // Wallet not configured
960
+ if (/wallet|signer|no.*account|not.*connected/i.test(msg)) {
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\``;
966
+ }
967
+ // Auth / token issues
968
+ if (/auth|unauthorized|403|401|token.*expired|challenge/i.test(msg)) {
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\``;
973
+ }
974
+ // Network / RPC failures
975
+ if (/rpc|network|timeout|ECONNREFUSED|ENOTFOUND|fetch.*failed/i.test(msg)) {
976
+ return `${msg}\n\nHint: Check your network connection. Set SAGE_PROFILE to switch between testnet/mainnet.`;
977
+ }
978
+ // MCP bridge not running
979
+ if (/not running|not initialized|bridge stopped/i.test(msg)) {
980
+ return `${msg}\n\nHint: The Sage MCP bridge may have crashed. Try restarting the plugin or running \`sage mcp start\` to verify the CLI works.`;
981
+ }
982
+ // Credits
983
+ if (/credits|insufficient.*balance|IPFS.*balance/i.test(msg)) {
984
+ return `${msg}\n\nHint: Run \`sage config ipfs faucet\` (testnet; legacy: \`sage ipfs faucet\`) or purchase credits via \`sage wallet buy\`.`;
985
+ }
986
+
987
+ return msg;
988
+ }
989
+
990
+ function registerStatusTool(api: PluginApi, sageToolCount: number) {
991
+ api.registerTool(
992
+ {
993
+ name: "sage_status",
994
+ label: "Sage: status",
995
+ description:
996
+ "Check Sage plugin health: bridge connection, tool count, network profile, and wallet status",
997
+ parameters: Type.Object({}),
998
+ execute: async () => {
999
+ const bridgeReady = sageBridge?.isReady() ?? false;
1000
+
1001
+ // Try to get wallet + network info from sage
1002
+ let walletInfo = "unknown";
1003
+ let networkInfo = "unknown";
1004
+ if (bridgeReady && sageBridge) {
1005
+ try {
1006
+ const ctx = await sageSearch({
1007
+ domain: "meta",
1008
+ action: "get_project_context",
1009
+ params: {},
1010
+ });
1011
+ const json = extractJsonFromMcpResult(ctx) as any;
1012
+ if (json?.wallet?.address) walletInfo = json.wallet.address;
1013
+ if (json?.network) networkInfo = json.network;
1014
+ } catch {
1015
+ // Not critical — report what we can
1016
+ }
1017
+ }
1018
+
1019
+ const status = {
1020
+ pluginVersion: PKG_VERSION,
1021
+ bridgeConnected: bridgeReady,
1022
+ sageToolCount,
1023
+ wallet: walletInfo,
1024
+ network: networkInfo,
1025
+ profile: process.env.SAGE_PROFILE || "default",
1026
+ };
1027
+
1028
+ return {
1029
+ content: [{ type: "text" as const, text: JSON.stringify(status, null, 2) }],
1030
+ details: status,
1031
+ };
1032
+ },
1033
+ },
1034
+ { name: "sage_status", optional: true },
1035
+ );
1036
+ }
1037
+
1038
+ function registerCodeModeTools(
539
1039
  api: PluginApi,
540
- prefix: string,
541
- bridge: McpBridge,
542
- tool: McpToolDef,
543
- opts?: {
1040
+ opts: {
1041
+ injectionGuardEnabled: boolean;
544
1042
  injectionGuardScanGetPrompt: boolean;
545
1043
  injectionGuardMode: "warn" | "block";
546
1044
  scanText: (text: string) => Promise<SecurityScanResult | null>;
547
1045
  },
548
1046
  ) {
549
- const name = `${prefix}_${tool.name}`;
550
- const schema = mcpSchemaToTypebox(tool.inputSchema);
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
+ }
1071
+
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
+ );
551
1085
 
552
1086
  api.registerTool(
553
1087
  {
554
- name,
555
- label: `${prefix}: ${tool.name}`,
556
- description: tool.description ?? `MCP tool: ${prefix}/${tool.name}`,
557
- parameters: schema,
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
+ }),
558
1096
  execute: async (_toolCallId: string, params: Record<string, unknown>) => {
559
1097
  try {
560
- const result = await bridge.callTool(tool.name, params);
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
+ }
561
1115
 
562
- if (opts?.injectionGuardScanGetPrompt && tool.name === "get_prompt" && prefix === "sage") {
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 });
1123
+
1124
+ if (opts.injectionGuardScanGetPrompt && domain === "prompts" && action === "get") {
563
1125
  const json = extractJsonFromMcpResult(result) as any;
564
1126
  const content =
565
1127
  typeof json?.prompt?.content === "string"
@@ -578,12 +1140,8 @@ function registerMcpTool(
578
1140
  );
579
1141
  }
580
1142
 
581
- // Warn mode: attach a compact summary to the JSON output.
582
1143
  if (json && typeof json === "object") {
583
- json.security = {
584
- shouldBlock: true,
585
- summary,
586
- };
1144
+ json.security = { shouldBlock: true, summary };
587
1145
  return {
588
1146
  content: [{ type: "text" as const, text: JSON.stringify(json) }],
589
1147
  details: result,
@@ -595,21 +1153,27 @@ function registerMcpTool(
595
1153
 
596
1154
  return toToolResult(result);
597
1155
  } catch (err) {
598
- return toToolResult({
599
- error: err instanceof Error ? err.message : String(err),
600
- });
1156
+ const enriched = enrichErrorMessage(
1157
+ err instanceof Error ? err : new Error(String(err)),
1158
+ "sage_execute",
1159
+ );
1160
+ return toToolResult({ error: enriched });
601
1161
  }
602
1162
  },
603
1163
  },
604
- { name, optional: true },
1164
+ { name: "sage_execute", optional: true },
605
1165
  );
606
1166
  }
607
1167
 
608
1168
  export default plugin;
609
1169
 
610
1170
  export const __test = {
611
- SAGE_CONTEXT,
1171
+ PKG_VERSION,
1172
+ SAGE_CONTEXT: SAGE_FULL_CONTEXT,
612
1173
  normalizePrompt,
613
1174
  extractJsonFromMcpResult,
614
1175
  formatSkillSuggestions,
1176
+ mcpSchemaToTypebox,
1177
+ jsonSchemaToTypebox,
1178
+ enrichErrorMessage,
615
1179
  };