@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/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, type McpToolDef } from "./mcp-bridge.js";
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 MCP Tools Available
22
+ const SAGE_CONTEXT = `## Sage (Code Mode)
23
23
 
24
- You have access to Sage MCP tools for prompts, skills, governance, and on-chain operations.
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
- ### Prompt Discovery
27
- - \`search_prompts\` - Hybrid keyword + semantic search for prompts
28
- - \`list_prompts\` - Browse prompts by source (local/onchain)
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
- ### Skills
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
- ### Governance & DAOs
40
- - \`list_subdaos\` - List available DAOs
41
- - \`list_proposals\` / \`sage_list_governance_proposals\` - View proposals
42
- - \`sage_list_governance_votes\` - View vote breakdown
43
- - \`get_voting_power\` - Check voting power with NFT multipliers
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
- ### Tips, Bounties & Marketplace
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
- ### Chat & Social
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?: { 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
+ };
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 = typeof r.library === "string" && r.library.trim() ? ` (from ${r.library.trim()})` : "";
207
- const servers = Array.isArray(r.mcpServers) && r.mcpServers.length ? ` requires: ${r.mcpServers.join(", ")}` : "";
208
- 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
+ );
209
191
  }
210
192
  return lines.join("\n");
211
193
  }
212
194
 
213
- /** Custom server configuration from mcp-servers.toml */
214
- type CustomServerConfig = {
215
- id: string;
216
- name: string;
217
- description?: string;
218
- enabled: boolean;
219
- source: {
220
- type: "npx" | "node" | "binary";
221
- package?: string;
222
- path?: string;
223
- };
224
- extra_args?: string[];
225
- 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>;
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 => ["string", "number", "boolean"].includes(typeof v))
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 = items && typeof items === "object" ? jsonSchemaToTypebox(items) : Type.Unknown();
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 loadCustomServers(): CustomServerConfig[] {
325
- const configPath = join(homedir(), ".config", "sage", "mcp-servers.toml");
326
-
327
- if (!existsSync(configPath)) {
328
- return [];
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
- * Create command and args for spawning an external server
372
- */
373
- function getServerCommand(server: CustomServerConfig): { command: string; args: string[] } {
374
- switch (server.source.type) {
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 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",
406
535
 
407
536
  register(api: PluginApi) {
408
537
  const pluginCfg = api.pluginConfig ?? {};
409
- const sageBinary = typeof pluginCfg.sageBinary === "string" && pluginCfg.sageBinary.trim()
410
- ? pluginCfg.sageBinary.trim()
411
- : "sage";
412
- const sageProfile = typeof pluginCfg.sageProfile === "string" && pluginCfg.sageProfile.trim()
413
- ? pluginCfg.sageProfile.trim()
414
- : undefined;
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 = injectionGuardEnabled && pluginCfg.injectionGuardUsePromptGuard === true;
573
+ const injectionGuardUsePromptGuard =
574
+ injectionGuardEnabled && pluginCfg.injectionGuardUsePromptGuard === true;
432
575
  const injectionGuardMaxChars = clampInt(pluginCfg.injectionGuardMaxChars, 32_768, 256, 200_000);
433
- 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";
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 sageBridge.callTool("security_scan_text", {
451
- text: trimmed,
452
- maxChars: injectionGuardMaxChars,
453
- maxEvidenceLen: 100,
454
- includeEvidence: injectionGuardIncludeEvidence,
455
- 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
+ },
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", "SAGE_PAY_TO_PIN", "SAGE_IPFS_WORKER_URL",
483
- "SAGE_IPFS_UPLOAD_TOKEN", "SAGE_API_URL", "SAGE_HOME",
484
- "KEYSTORE_PASSWORD", "SAGE_PROMPT_GUARD_API_KEY",
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} internal MCP tools`);
804
+ ctx.logger.info(`Discovered ${tools.length} Sage MCP tools`);
511
805
 
512
- for (const tool of tools) {
513
- registerMcpTool(api, "sage", sageBridge!, tool, {
514
- injectionGuardScanGetPrompt,
515
- injectionGuardMode,
516
- scanText,
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
- const prompt = normalizePrompt(typeof event?.prompt === "string" ? event.prompt : "", {
581
- maxBytes: maxPromptBytes,
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 (autoInject) parts.push(SAGE_CONTEXT);
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
- 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()) {
606
913
  try {
607
- const raw = await sageBridge.callTool("search_skills", {
608
- query: prompt,
609
- source: "all",
610
- 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
+ },
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 (autoInject) parts.push(SAGE_CONTEXT);
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
- return `${msg}\n\nHint: Run \`sage ipfs setup\` to refresh authentication, or check SAGE_IPFS_UPLOAD_TOKEN.`;
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: "Check Sage plugin health: bridge connection, tool count, network profile, and wallet status",
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 sageBridge.callTool("get_project_context", {});
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 registerMcpTool(
1038
+ function registerCodeModeTools(
707
1039
  api: PluginApi,
708
- prefix: string,
709
- bridge: McpBridge,
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
- const name = `${prefix}_${tool.name}`;
718
- 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
+ }
719
1071
 
720
- // Extract category from tool annotations if available
721
- const category = typeof tool.annotations?.category === "string"
722
- ? tool.annotations.category
723
- : undefined;
724
- const label = category
725
- ? `${prefix}: ${category} / ${tool.name}`
726
- : `${prefix}: ${tool.name}`;
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: tool.description ?? `MCP tool: ${prefix}/${tool.name}`,
733
- 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
+ }),
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 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
+ }
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?.injectionGuardScanGetPrompt && tool.name === "get_prompt" && prefix === "sage") {
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
- tool.name,
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,