@sage-protocol/pi-adapter 0.3.6 → 0.3.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.
@@ -1,6 +1,8 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
  import { spawn } from "node:child_process";
3
3
  import { randomUUID } from "node:crypto";
4
+ import { createRequire } from "node:module";
5
+ const require = createRequire(import.meta.url);
4
6
  // ── Unicode glyph fallback ──
5
7
  // Some terminals lack glyph support for arrows, checkmarks, and dotted separators.
6
8
  // Set SAGE_NO_UNICODE=1 to force ASCII-safe output in widget, summary, and quality icons.
@@ -46,9 +48,7 @@ function formatSageCommand(command, args) {
46
48
  const quoted = [command, ...args].map((part) => {
47
49
  if (!part)
48
50
  return "''";
49
- return /[\s"'`$\\]/.test(part)
50
- ? JSON.stringify(part)
51
- : part;
51
+ return /[\s"'`$\\]/.test(part) ? JSON.stringify(part) : part;
52
52
  });
53
53
  return quoted.join(" ");
54
54
  }
@@ -101,7 +101,10 @@ const EXTERNAL_EXECUTE_ACTIONS = new Set(["execute", "call"]);
101
101
  const EXTERNAL_TOOL_ALIASES = {
102
102
  web_search: "duckduckgo_web_search",
103
103
  };
104
- const WEB_SEARCH_PROVIDER_TOOLS = new Set(["duckduckgo_web_search", "brave_web_search"]);
104
+ const WEB_SEARCH_PROVIDER_TOOLS = new Set([
105
+ "duckduckgo_web_search",
106
+ "brave_web_search",
107
+ ]);
105
108
  const WEB_SEARCH_RETRY_PATTERNS = [
106
109
  /ddg detected an anomaly/i,
107
110
  /too quickly/i,
@@ -119,7 +122,8 @@ const WEB_SEARCH_MIN_INTERVAL_MS = 250;
119
122
  const WEB_SEARCH_RETRY_BASE_MS = 250;
120
123
  const LAST_WEB_SEARCH_CALL_BY_PROVIDER = new Map();
121
124
  const DAEMON_IPC_MAX_ATTEMPTS = Math.max(1, Number.parseInt(process.env.SAGE_DAEMON_IPC_MAX_ATTEMPTS || "3", 10) || 3);
122
- const DAEMON_IPC_RETRY_BASE_MS = Math.max(50, Number.parseInt(process.env.SAGE_DAEMON_IPC_RETRY_BASE_MS || "350", 10) || 350);
125
+ const DAEMON_IPC_RETRY_BASE_MS = Math.max(50, Number.parseInt(process.env.SAGE_DAEMON_IPC_RETRY_BASE_MS || "350", 10) ||
126
+ 350);
123
127
  const DOMAINLESS_ACTION_DOMAIN_HINTS = {
124
128
  search: "prompts",
125
129
  list: "prompts",
@@ -156,6 +160,7 @@ const DOMAINLESS_ACTION_DOMAIN_HINTS = {
156
160
  };
157
161
  const DEFAULT_SEARCH_ACTION_BY_DOMAIN = {
158
162
  prompts: "list",
163
+ behaviors: "recommend",
159
164
  skills: "list",
160
165
  builder: "recommend",
161
166
  governance: "list_subdaos",
@@ -166,6 +171,8 @@ const DEFAULT_SEARCH_ACTION_BY_DOMAIN = {
166
171
  security: "scan",
167
172
  meta: "get_project_context",
168
173
  hub: "status",
174
+ activation: "active",
175
+ wallet: "balance",
169
176
  library: "get_active",
170
177
  help: "list",
171
178
  external: "list_servers",
@@ -280,7 +287,9 @@ async function callWithDaemonRetry(call, args) {
280
287
  await wait(daemonRetryBackoffMs(attempt));
281
288
  }
282
289
  }
283
- throw lastError instanceof Error ? lastError : new Error(String(lastError ?? "unknown daemon IPC failure"));
290
+ throw lastError instanceof Error
291
+ ? lastError
292
+ : new Error(String(lastError ?? "unknown daemon IPC failure"));
284
293
  }
285
294
  function withExternalToolName(request, toolName) {
286
295
  return {
@@ -310,7 +319,8 @@ async function callCodeModeWithWebResilience(mcpBridge, mode, request) {
310
319
  }
311
320
  catch (error) {
312
321
  failures.push(`${provider} (attempt ${attempt}): ${toErrorMessage(error)}`);
313
- const shouldRetry = isTransientWebSearchError(error) && attempt < WEB_SEARCH_MAX_ATTEMPTS_PER_PROVIDER;
322
+ const shouldRetry = isTransientWebSearchError(error) &&
323
+ attempt < WEB_SEARCH_MAX_ATTEMPTS_PER_PROVIDER;
314
324
  if (shouldRetry) {
315
325
  await wait(retryBackoffMs(attempt));
316
326
  continue;
@@ -346,7 +356,8 @@ function normalizeCodeModeRequest(mode, args) {
346
356
  if (!rawAction) {
347
357
  if (mode === "search") {
348
358
  const fallbackDomain = rawDomain || "help";
349
- const fallbackAction = DEFAULT_SEARCH_ACTION_BY_DOMAIN[fallbackDomain] || DEFAULT_SEARCH_ACTION_BY_DOMAIN.help;
359
+ const fallbackAction = DEFAULT_SEARCH_ACTION_BY_DOMAIN[fallbackDomain] ||
360
+ DEFAULT_SEARCH_ACTION_BY_DOMAIN.help;
350
361
  return {
351
362
  domain: fallbackDomain,
352
363
  action: fallbackAction,
@@ -585,8 +596,314 @@ function appendText(result, text) {
585
596
  existing.push({ type: "text", text });
586
597
  return { ...result, content: existing };
587
598
  }
599
+ function createTextComponent(text) {
600
+ for (const moduleName of ["@earendil-works/pi-tui", "@mariozechner/pi-tui"]) {
601
+ try {
602
+ const mod = require(moduleName);
603
+ if (mod.Text)
604
+ return new mod.Text(text, 0, 0);
605
+ }
606
+ catch (_error) {
607
+ void _error;
608
+ }
609
+ }
610
+ return {
611
+ text,
612
+ setText(next) {
613
+ this.text = next;
614
+ },
615
+ };
616
+ }
617
+ function truncateText(text, max = 80) {
618
+ return text.length > max ? `${text.slice(0, Math.max(0, max - 3))}...` : text;
619
+ }
620
+ function quotedText(text, max = 72) {
621
+ return `"${truncateText(text, max)}"`;
622
+ }
623
+ function stringValue(value) {
624
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
625
+ }
626
+ function toolParamValue(request) {
627
+ return normalizeParams(request.params.tool_params);
628
+ }
629
+ function normalizeRenderRequest(mode, args) {
630
+ try {
631
+ return normalizeCodeModeRequest(mode, normalizeParams(args));
632
+ }
633
+ catch {
634
+ return null;
635
+ }
636
+ }
637
+ function formatOperationSubject(request) {
638
+ if (request.domain === "external") {
639
+ const toolName = stringValue(request.params.tool_name) ?? "tool";
640
+ return `${request.action} ${toolName}`;
641
+ }
642
+ return `${request.domain}/${request.action}`;
643
+ }
644
+ function summarizeInventoryItem(value) {
645
+ if (typeof value === "string" && value.trim()) {
646
+ return truncateText(value.trim(), 96);
647
+ }
648
+ if (!value || typeof value !== "object")
649
+ return null;
650
+ const item = value;
651
+ const toolName = stringValue(item.tool_name);
652
+ const sessionId = stringValue(item.session_id);
653
+ const instanceId = stringValue(item.instance_id);
654
+ const serverId = stringValue(item.server_id) ?? stringValue(item.id) ?? instanceId;
655
+ if (toolName && serverId) {
656
+ return truncateText(`${toolName} @ ${serverId}`, 96);
657
+ }
658
+ if (instanceId) {
659
+ return truncateText(sessionId ? `${instanceId} (session ${sessionId})` : instanceId, 96);
660
+ }
661
+ const label = stringValue(item.title) ??
662
+ stringValue(item.name) ??
663
+ stringValue(item.key) ??
664
+ stringValue(item.id) ??
665
+ stringValue(item.url) ??
666
+ serverId;
667
+ if (!label)
668
+ return null;
669
+ const suffix = [];
670
+ if (typeof item.running === "boolean") {
671
+ suffix.push(item.running ? "running" : "stopped");
672
+ }
673
+ if (typeof item.enabled === "boolean") {
674
+ suffix.push(item.enabled ? "enabled" : "disabled");
675
+ }
676
+ if (typeof item.available === "boolean" && item.available === false) {
677
+ suffix.push("unavailable");
678
+ }
679
+ if (Array.isArray(item.tools)) {
680
+ suffix.push(`${item.tools.length} ${item.tools.length === 1 ? "tool" : "tools"}`);
681
+ }
682
+ else if (typeof item.tool_count === "number") {
683
+ suffix.push(`${item.tool_count} ${item.tool_count === 1 ? "tool" : "tools"}`);
684
+ }
685
+ const status = stringValue(item.status);
686
+ if (status && !suffix.includes(status))
687
+ suffix.push(status);
688
+ const decorated = suffix.length > 0 ? `${label} (${suffix.slice(0, 3).join(", ")})` : label;
689
+ return truncateText(decorated, 96);
690
+ }
691
+ function formatOperationDetails(request) {
692
+ const details = [];
693
+ if (request.domain === "external") {
694
+ const toolName = stringValue(request.params.tool_name);
695
+ const toolParams = toolParamValue(request);
696
+ const query = stringValue(toolParams.query);
697
+ const url = stringValue(toolParams.url);
698
+ const libraryId = stringValue(toolParams.context7CompatibleLibraryID);
699
+ if (toolName)
700
+ details.push(`tool: ${toolName}`);
701
+ if (query)
702
+ details.push(`query: ${quotedText(query)}`);
703
+ if (url)
704
+ details.push(`url: ${truncateText(url, 84)}`);
705
+ if (libraryId)
706
+ details.push(`library: ${truncateText(libraryId, 84)}`);
707
+ return details;
708
+ }
709
+ const query = stringValue(request.params.query);
710
+ const key = stringValue(request.params.key) ?? stringValue(request.params.name);
711
+ const serverId = stringValue(request.params.server_id);
712
+ const sessionId = stringValue(request.params.session_id);
713
+ const library = stringValue(request.params.library);
714
+ if (query)
715
+ details.push(`query: ${quotedText(query)}`);
716
+ if (key)
717
+ details.push(`key: ${truncateText(key, 84)}`);
718
+ if (serverId)
719
+ details.push(`server: ${serverId}`);
720
+ if (sessionId)
721
+ details.push(`session: ${truncateText(sessionId, 84)}`);
722
+ if (library)
723
+ details.push(`library: ${truncateText(library, 84)}`);
724
+ return details;
725
+ }
726
+ function summarizePayloadItems(payload) {
727
+ const namedArrays = [
728
+ ["results", "results"],
729
+ ["tools", "tools"],
730
+ ["servers", "servers"],
731
+ ["running_servers", "servers"],
732
+ ["sessions", "sessions"],
733
+ ["skills", "skills"],
734
+ ["prompts", "prompts"],
735
+ ["libraries", "libraries"],
736
+ ["items", "items"],
737
+ ["rooms", "rooms"],
738
+ ["messages", "messages"],
739
+ ];
740
+ if (Array.isArray(payload)) {
741
+ return {
742
+ count: payload.length,
743
+ label: payload.length === 1 ? "item" : "items",
744
+ items: payload
745
+ .map(summarizeInventoryItem)
746
+ .filter((item) => Boolean(item))
747
+ .slice(0, 4),
748
+ };
749
+ }
750
+ if (!payload || typeof payload !== "object")
751
+ return null;
752
+ const obj = payload;
753
+ for (const [key, label] of namedArrays) {
754
+ const items = obj[key];
755
+ if (!Array.isArray(items))
756
+ continue;
757
+ return {
758
+ count: items.length,
759
+ label: items.length === 1 ? label.replace(/s$/, "") : label,
760
+ items: items
761
+ .map(summarizeInventoryItem)
762
+ .filter((item) => Boolean(item))
763
+ .slice(0, 4),
764
+ };
765
+ }
766
+ return null;
767
+ }
768
+ function defaultOperationStatus(request) {
769
+ switch (request?.action) {
770
+ case "search":
771
+ return "searched";
772
+ case "list":
773
+ return "loaded";
774
+ case "get":
775
+ return "opened";
776
+ case "use":
777
+ return "activated";
778
+ case "sync":
779
+ return "synced";
780
+ case "start":
781
+ return "started";
782
+ case "stop":
783
+ return "stopped";
784
+ case "status":
785
+ return "status loaded";
786
+ default:
787
+ return "completed";
788
+ }
789
+ }
790
+ function summarizeRenderedResult(request, details, isError) {
791
+ if (isError) {
792
+ const errorText = extractText(details);
793
+ const preview = errorText
794
+ ? [truncateText(errorText.split(/\r?\n/)[0] ?? errorText, 120)]
795
+ : [];
796
+ return { status: "failed", preview };
797
+ }
798
+ const payload = extractJsonFromMcpResult(details) ?? details;
799
+ if (payload && typeof payload === "object") {
800
+ const obj = payload;
801
+ const explicitStatus = stringValue(obj.status);
802
+ if (request?.domain === "hub" && request.action === "start") {
803
+ const tools = Array.isArray(obj.tools)
804
+ ? obj.tools
805
+ .map((item) => summarizeInventoryItem(item))
806
+ .filter((item) => Boolean(item))
807
+ .slice(0, 3)
808
+ : [];
809
+ const preview = [
810
+ ...tools,
811
+ ...[
812
+ stringValue(obj.hint),
813
+ stringValue(obj.message),
814
+ stringValue(obj.summary),
815
+ ]
816
+ .filter((item) => Boolean(item))
817
+ .map((item) => truncateText(item, 120)),
818
+ ].slice(0, 3);
819
+ return {
820
+ status: explicitStatus ?? defaultOperationStatus(request),
821
+ preview,
822
+ };
823
+ }
824
+ const listSummary = summarizePayloadItems(payload);
825
+ if (listSummary) {
826
+ const preview = listSummary.items.length
827
+ ? listSummary.items
828
+ : stringValue(obj.hint)
829
+ ? [truncateText(stringValue(obj.hint) ?? "", 120)]
830
+ : [];
831
+ return {
832
+ status: `${listSummary.count} ${listSummary.label}`,
833
+ preview,
834
+ };
835
+ }
836
+ const message = stringValue(obj.message) ??
837
+ stringValue(obj.summary) ??
838
+ stringValue(obj.hint);
839
+ if (message) {
840
+ return {
841
+ status: explicitStatus ?? defaultOperationStatus(request),
842
+ preview: [truncateText(message, 120)],
843
+ };
844
+ }
845
+ if (explicitStatus) {
846
+ return { status: explicitStatus, preview: [] };
847
+ }
848
+ if (obj.ok === true) {
849
+ return { status: defaultOperationStatus(request), preview: [] };
850
+ }
851
+ }
852
+ return { status: defaultOperationStatus(request), preview: [] };
853
+ }
854
+ function renderSageToolCall(mode, args, theme) {
855
+ const request = normalizeRenderRequest(mode, args);
856
+ const toolName = mode === "search" ? "sage_search " : "sage_execute ";
857
+ if (!request) {
858
+ return createTextComponent(theme.fg("toolTitle", theme.bold(toolName)) +
859
+ theme.fg("warning", "(invalid request)"));
860
+ }
861
+ const lines = [
862
+ theme.fg("toolTitle", theme.bold(toolName)) +
863
+ theme.fg("accent", formatOperationSubject(request)),
864
+ ];
865
+ for (const detail of formatOperationDetails(request).slice(0, 3)) {
866
+ lines.push(theme.fg("dim", ` ${detail}`));
867
+ }
868
+ return createTextComponent(lines.join("\n"));
869
+ }
870
+ function renderSageToolResult(mode, result, options, theme, context) {
871
+ if (options.isPartial) {
872
+ return createTextComponent(theme.fg("warning", mode === "search" ? "Searching..." : "Executing..."));
873
+ }
874
+ const request = normalizeRenderRequest(mode, context?.args);
875
+ const summary = summarizeRenderedResult(request, result.details, Boolean(context?.isError ?? result.isError));
876
+ const statusColor = summary.status === "failed" ? "error" : "success";
877
+ const lines = [
878
+ theme.fg(statusColor, summary.status) +
879
+ theme.fg("muted", " · ") +
880
+ theme.fg("accent", request
881
+ ? formatOperationSubject(request)
882
+ : mode === "search"
883
+ ? "search"
884
+ : "execute"),
885
+ ];
886
+ for (const detail of request
887
+ ? formatOperationDetails(request).slice(0, 2)
888
+ : []) {
889
+ lines.push(theme.fg("dim", ` ${detail}`));
890
+ }
891
+ if (options.expanded) {
892
+ if (summary.preview.length > 0) {
893
+ for (const line of summary.preview) {
894
+ lines.push(theme.fg("muted", ` ${line}`));
895
+ }
896
+ }
897
+ else if (!(context?.isError ?? result.isError)) {
898
+ lines.push(theme.fg("dim", " raw payload hidden"));
899
+ }
900
+ }
901
+ return createTextComponent(lines.join("\n"));
902
+ }
588
903
  function looksUnknownToolError(error) {
589
- const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
904
+ const msg = error instanceof Error
905
+ ? error.message.toLowerCase()
906
+ : String(error).toLowerCase();
590
907
  return (msg.includes("unknown tool") ||
591
908
  msg.includes("not found") ||
592
909
  msg.includes("no such tool") ||
@@ -614,8 +931,8 @@ function parseSecurityDecision(stdout) {
614
931
  if (rawDecision === "allow")
615
932
  return { decision: "allow", reason };
616
933
  }
617
- catch {
618
- continue;
934
+ catch (_error) {
935
+ void _error;
619
936
  }
620
937
  }
621
938
  return null;
@@ -771,7 +1088,8 @@ export class SageMcpBridge {
771
1088
  catch (error) {
772
1089
  if (!looksUnknownToolError(error))
773
1090
  throw error;
774
- lastUnknownError = error instanceof Error ? error : new Error(String(error));
1091
+ lastUnknownError =
1092
+ error instanceof Error ? error : new Error(String(error));
775
1093
  }
776
1094
  }
777
1095
  if (lastUnknownError)
@@ -807,7 +1125,7 @@ export class SageMcpBridge {
807
1125
  proc.on("exit", (code) => {
808
1126
  if (this.stopped)
809
1127
  return;
810
- const message = this.withRecentStderr(`MCP process exited${this.ready ? '' : ' before initialization'} (code ${code ?? -1})`);
1128
+ const message = this.withRecentStderr(`MCP process exited${this.ready ? "" : " before initialization"} (code ${code ?? -1})`);
811
1129
  this.ready = false;
812
1130
  this.rejectAllPending(message);
813
1131
  rejectOnce(new Error(message));
@@ -930,7 +1248,9 @@ export class SageHookRuntime {
930
1248
  this.run(["library", "active"], {}, undefined, true),
931
1249
  ]);
932
1250
  const libraries = [];
933
- const libraryOut = libraryResult.status === "fulfilled" ? libraryResult.value?.stdout.trim() ?? "" : "";
1251
+ const libraryOut = libraryResult.status === "fulfilled"
1252
+ ? (libraryResult.value?.stdout.trim() ?? "")
1253
+ : "";
934
1254
  if (libraryOut) {
935
1255
  for (const line of libraryOut.split("\n")) {
936
1256
  const m = line.match(/^\s*(.+?)\s+\((\d+)\s+prompts?,\s*(\d+)\s+skills?\)/);
@@ -945,7 +1265,9 @@ export class SageHookRuntime {
945
1265
  }
946
1266
  const totalSkills = libraries.reduce((s, l) => s + l.skills, 0);
947
1267
  // --- Active libraries ---
948
- const activeOut = activeResult.status === "fulfilled" ? activeResult.value?.stdout.trim() ?? "" : "";
1268
+ const activeOut = activeResult.status === "fulfilled"
1269
+ ? (activeResult.value?.stdout.trim() ?? "")
1270
+ : "";
949
1271
  const activeNames = [];
950
1272
  if (activeOut) {
951
1273
  for (const line of activeOut.split("\n")) {
@@ -973,7 +1295,9 @@ export class SageHookRuntime {
973
1295
  return name;
974
1296
  });
975
1297
  const SEP = unicodeSafe("\u00B7"); // middle dot or |
976
- const suffix = activeNames.length > maxShow ? ` ${SEP} +${activeNames.length - maxShow} more` : "";
1298
+ const suffix = activeNames.length > maxShow
1299
+ ? ` ${SEP} +${activeNames.length - maxShow} more`
1300
+ : "";
977
1301
  summary = shown.join(` ${SEP} `) + suffix;
978
1302
  }
979
1303
  else if (libraries.length > 0) {
@@ -986,7 +1310,13 @@ export class SageHookRuntime {
986
1310
  const trimmed = prompt.trim();
987
1311
  if (!trimmed)
988
1312
  return "";
989
- const args = ["suggest", "hook", "skill", "--limit", String(this.config.suggestLimit ?? 3)];
1313
+ const args = [
1314
+ "suggest",
1315
+ "hook",
1316
+ "skill",
1317
+ "--limit",
1318
+ String(this.config.suggestLimit ?? 3),
1319
+ ];
990
1320
  if (this.config.enableProvision !== false)
991
1321
  args.push("--provision");
992
1322
  const result = await this.run(args, {
@@ -1028,7 +1358,9 @@ export class SageHookRuntime {
1028
1358
  async recordSuggestionUse(skillKeys, sessionId) {
1029
1359
  if (this.config.enableRlmFeedback === false)
1030
1360
  return;
1031
- const used = [...new Set(skillKeys.map((key) => key.trim()).filter(Boolean))];
1361
+ const used = [
1362
+ ...new Set(skillKeys.map((key) => key.trim()).filter(Boolean)),
1363
+ ];
1032
1364
  if (used.length === 0)
1033
1365
  return;
1034
1366
  await this.run(["suggest", "feedback", "use", ...used.flatMap((key) => ["--used", key])], {
@@ -1044,7 +1376,7 @@ export class SageHookRuntime {
1044
1376
  SAGE_SESSION_ID: sessionId ?? "",
1045
1377
  }, undefined, true);
1046
1378
  }
1047
- async recordExecutionOutcome(skillKey, status, queryPreview, sessionId, model) {
1379
+ async recordExecutionOutcome(skillKey, status, queryPreview, sessionId, _model) {
1048
1380
  if (this.config.enableRlmFeedback === false)
1049
1381
  return;
1050
1382
  const key = skillKey.trim();
@@ -1201,6 +1533,7 @@ export class SageHookRuntime {
1201
1533
  }
1202
1534
  const SageDomainType = Type.Union([
1203
1535
  Type.Literal("prompts"),
1536
+ Type.Literal("behaviors"),
1204
1537
  Type.Literal("skills"),
1205
1538
  Type.Literal("builder"),
1206
1539
  Type.Literal("governance"),
@@ -1211,6 +1544,8 @@ const SageDomainType = Type.Union([
1211
1544
  Type.Literal("security"),
1212
1545
  Type.Literal("meta"),
1213
1546
  Type.Literal("hub"),
1547
+ Type.Literal("activation"),
1548
+ Type.Literal("wallet"),
1214
1549
  Type.Literal("library"),
1215
1550
  Type.Literal("help"),
1216
1551
  Type.Literal("external"),
@@ -1219,32 +1554,33 @@ export function createSageSearchTool(mcpBridge) {
1219
1554
  return {
1220
1555
  name: "sage_search",
1221
1556
  label: "Sage: Search",
1222
- promptSnippet: "Search the web, documentation, prompts, skills, and external services via Sage MCP bridge",
1557
+ promptSnippet: "Route read-only Sage code-mode requests: discover domains/actions, inspect context, query hub/external tools, and search capabilities",
1223
1558
  promptGuidelines: [
1224
- "Use sage_search (NOT bash sage commands) for web searches, documentation lookups, and prompt/skill discovery",
1225
- "NEVER run 'sage' CLI via bash -- always use the sage_search or sage_execute tools instead",
1226
- "For web search: sage_search({ domain: 'external', action: 'search', params: { tool_name: 'web_search', tool_params: { query: '...' } } })",
1227
- "To list available tools: sage_search({ domain: 'external', action: 'list_servers' })",
1559
+ "Use sage_search (NOT bash sage commands) for Sage read/discovery flows such as help.list, prompts.search, behaviors.show, meta.get_project_context, meta.suggest, hub.list_servers/status, wallet.balance/address, and external.search",
1560
+ "Use sage_search({ domain: 'help', action: 'list', params: {} }) before guessing unknown Sage domains or actions",
1561
+ "Use sage_search({ domain: 'hub', action: 'list_servers', params: {} }) for MCP server inventory; use domain='external', action='list_servers' only to inspect running external servers and tool exposure",
1562
+ "If Sage says an action is a mutation or non-search inspect action, retry with sage_execute instead of falling back to bash",
1228
1563
  ],
1229
- description: "Search the web, find documentation, search prompts/skills, and query external services. " +
1230
- "For web search: domain='external', action='search', params={tool_name:'web_search', tool_params:{query:'your query'}}. " +
1231
- "For prompt search: domain='prompts', action='search', params={query:'search terms'}. " +
1232
- "For skill search: domain='skills', action='search', params={query:'search terms'}. " +
1233
- "For GitHub code search: domain='external', action='search', params={tool_name:'github_search_code', tool_params:{query:'...'}}. " +
1234
- "For library docs: domain='external', action='search', params={tool_name:'get_library_docs', tool_params:{context7CompatibleLibraryID:'/lib/id'}}. " +
1235
- "To list available external servers/tools: domain='external', action='list_servers'.",
1564
+ description: "Route read-only Sage code-mode requests across prompts, behaviors, skills, builder, governance, chat, social, RLM, security, meta, hub, activation, wallet, library, help, and external services. " +
1565
+ "Start with domain='help', action='list' when you do not know the route. " +
1566
+ "Use domain='meta', action='get_project_context' for local Sage state and domain='meta', action='suggest' with params={goal:'...'} when you need route suggestions. " +
1567
+ "Use domain='hub' for MCP hub inventory/status and domain='external' for external tool discovery/search. " +
1568
+ "Some heavy inspect actions such as prompts.get and skills.get are classified by Sage as non-search and should use sage_execute.",
1236
1569
  parameters: Type.Object({
1237
1570
  domain: Type.Optional(SageDomainType),
1238
1571
  action: Type.Optional(Type.String()),
1239
1572
  params: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
1240
1573
  }),
1574
+ renderCall: (args, theme) => renderSageToolCall("search", args, theme),
1575
+ renderResult: (result, options, theme, context) => renderSageToolResult("search", result, options, theme, context),
1241
1576
  execute: async (_toolCallId, params) => {
1242
1577
  try {
1243
1578
  const normalized = normalizeCodeModeRequest("search", params);
1244
1579
  const domain = normalized.domain;
1245
1580
  const action = normalized.action;
1246
1581
  const payload = normalized.params;
1247
- if (domain === "external" && !["list_servers", "search"].includes(action)) {
1582
+ if (domain === "external" &&
1583
+ !["list_servers", "search"].includes(action)) {
1248
1584
  return {
1249
1585
  content: [
1250
1586
  {
@@ -1280,21 +1616,22 @@ export function createSageExecuteTool(mcpBridge) {
1280
1616
  return {
1281
1617
  name: "sage_execute",
1282
1618
  label: "Sage: Execute",
1283
- promptSnippet: "Execute mutations: call external tools, use pi's hub-compat layer, create prompts, fetch URLs",
1619
+ promptSnippet: "Execute Sage mutations and non-search inspect actions: activate skills, fetch full prompt/skill bodies, manage hub servers, and call external tools",
1284
1620
  promptGuidelines: [
1285
- "Use sage_execute (NOT bash sage commands) for calling external tools, fetching URLs, and managing MCP servers",
1621
+ "Use sage_execute (NOT bash sage commands) for Sage mutations and for Sage actions that the code-mode router classifies as non-search, including prompts.get, skills.get/use, behaviors.run, activation.activate/deactivate, library writes, hub.start/stop, and external.call/execute",
1622
+ "After starting an MCP server with sage_execute({ domain: 'hub', action: 'start', params: { server_id: '...' } }), call its tools through domain='external' with params { tool_name, tool_params } instead of invoking sage via bash",
1286
1623
  "To fetch a URL: sage_execute({ domain: 'external', action: 'call', params: { tool_name: 'fetch', tool_params: { url: 'https://...' } } })",
1287
1624
  ],
1288
- description: "Execute mutations: create prompts, use skills, send chat messages, call external tools, and manage external MCP servers through pi's hub-compat wrapper. " +
1289
- "Preferred raw MCP clients use direct hub_* tools; in pi use domain='hub', action='start', params={server_id:'duckduckgo'} when needed. " +
1290
- "To use a skill: domain='skills', action='use', params={name:'skill-name'}. " +
1291
- "To call an external tool: domain='external', action='call', params={tool_name:'tool_name', tool_params:{...}}. " +
1292
- "To fetch a URL: domain='external', action='call', params={tool_name:'fetch', tool_params:{url:'https://example.com'}}.",
1625
+ description: "Execute Sage mutations and non-search inspect actions across prompts, behaviors, skills, builder, chat, library, activation, hub, and external tool surfaces. " +
1626
+ "Use this for prompts.get, skills.get/use, behaviors.run, activation.activate, library.prompt_add/skill_add, hub.start/stop, and external.call/execute. " +
1627
+ "Use domain='external', action='call' with params={tool_name:'fetch', tool_params:{url:'https://example.com'}} for URL fetches, and use domain='hub' to start or stop MCP servers before calling their tools.",
1293
1628
  parameters: Type.Object({
1294
1629
  domain: Type.Optional(SageDomainType),
1295
1630
  action: Type.Optional(Type.String()),
1296
1631
  params: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
1297
1632
  }),
1633
+ renderCall: (args, theme) => renderSageToolCall("execute", args, theme),
1634
+ renderResult: (result, options, theme, context) => renderSageToolResult("execute", result, options, theme, context),
1298
1635
  execute: async (_toolCallId, params) => {
1299
1636
  try {
1300
1637
  const normalized = normalizeCodeModeRequest("execute", params);
@@ -1320,32 +1657,7 @@ export function createSageExecuteTool(mcpBridge) {
1320
1657
  return toToolResultText(result);
1321
1658
  }
1322
1659
  catch (error) {
1323
- let errorMsg = error instanceof Error ? error.message : String(error);
1324
- // Detect hub start failures due to missing credentials and add helpful context
1325
- try {
1326
- const normalized = normalizeCodeModeRequest("execute", params);
1327
- if (normalized.domain === "hub" &&
1328
- normalized.action === "start" &&
1329
- /EOF|empty|parse|unexpected end/i.test(errorMsg)) {
1330
- const serverId = String(normalized.params.server_id ?? "unknown");
1331
- const knownKeyRequirements = {
1332
- "brave-search": "BRAVE_API_KEY",
1333
- github: "GITHUB_TOKEN",
1334
- };
1335
- const requiredKey = knownKeyRequirements[serverId];
1336
- if (requiredKey) {
1337
- errorMsg += `. Server '${serverId}' likely failed because ${requiredKey} is not configured. ` +
1338
- `Set it with: sage mcp hub env set ${serverId} ${requiredKey}=<your-key>`;
1339
- }
1340
- else {
1341
- errorMsg += `. Server '${serverId}' may require environment variables (API keys). ` +
1342
- `Check with: sage mcp hub env list ${serverId}`;
1343
- }
1344
- }
1345
- }
1346
- catch {
1347
- // normalizeCodeModeRequest can throw -- ignore, keep original errorMsg
1348
- }
1660
+ const errorMsg = augmentHubStartErrorMessage(params, error instanceof Error ? error.message : String(error));
1349
1661
  return {
1350
1662
  content: [
1351
1663
  {
@@ -1360,21 +1672,269 @@ export function createSageExecuteTool(mcpBridge) {
1360
1672
  };
1361
1673
  }
1362
1674
  const SAGE_CLI_REFERENCE = `
1363
- ## Sage CLI Reference (on-demand)
1675
+ ## Sage Code-Mode Reference
1364
1676
 
1365
- ### Skill Discovery
1366
- - sage_search({ domain: "skills", action: "search", params: { query: "..." } })
1367
- - sage_search({ domain: "skills", action: "get", params: { key: "..." } })
1677
+ ### Route Discovery
1678
+ - List domains/actions: sage_search({ domain: "help", action: "list", params: {} })
1679
+ - Suggest the right route: sage_search({ domain: "meta", action: "suggest", params: { goal: "..." } })
1680
+ - Inspect local Sage state: sage_search({ domain: "meta", action: "get_project_context", params: {} })
1368
1681
 
1369
- ### External Tools
1370
- - Web search: sage_search({ domain: "external", action: "search", params: { tool_name: "web_search", tool_params: { query: "..." } } })
1371
- - URL fetch: sage_execute({ domain: "external", action: "call", params: { tool_name: "fetch", tool_params: { url: "https://..." } } })
1372
- - List tools: sage_search({ domain: "external", action: "list_servers" })
1682
+ ### Skills / Prompts / Behaviors
1683
+ - Search skills: sage_search({ domain: "skills", action: "search", params: { query: "..." } })
1684
+ - Inspect skill body: sage_execute({ domain: "skills", action: "get", params: { key: "..." } })
1685
+ - Activate skill: sage_execute({ domain: "skills", action: "use", params: { key: "..." } })
1686
+ - Search prompts: sage_search({ domain: "prompts", action: "search", params: { query: "..." } })
1687
+ - Inspect prompt body: sage_execute({ domain: "prompts", action: "get", params: { key: "..." } })
1688
+ - Inspect behavior: sage_search({ domain: "behaviors", action: "show", params: { key: "..." } })
1373
1689
 
1374
- ### Hub
1375
- - Start server: sage_execute({ domain: "hub", action: "start", params: { server_id: "brave-search" } })
1376
- - Stop server: sage_execute({ domain: "hub", action: "stop", params: { server_id: "brave-search" } })
1690
+ ### Hub / External Tools
1691
+ - List MCP servers: sage_search({ domain: "hub", action: "list_servers", params: {} })
1692
+ - Hub status: sage_search({ domain: "hub", action: "status", params: {} })
1693
+ - Start server: sage_execute({ domain: "hub", action: "start", params: { server_id: "github" } })
1694
+ - Running external servers/tools: sage_search({ domain: "external", action: "list_servers", params: {} })
1695
+ - Web search: sage_search({ domain: "external", action: "search", params: { tool_name: "web_search", tool_params: { query: "..." } } })
1696
+ - URL fetch: sage_execute({ domain: "external", action: "call", params: { tool_name: "fetch", tool_params: { url: "https://..." } } })
1377
1697
  `.trim();
1698
+ function buildHubRenderContext(action, params, context) {
1699
+ return {
1700
+ ...(context && typeof context === "object" ? context : {}),
1701
+ args: {
1702
+ domain: "hub",
1703
+ action,
1704
+ params,
1705
+ },
1706
+ };
1707
+ }
1708
+ function augmentHubStartErrorMessage(params, errorMsg) {
1709
+ try {
1710
+ const normalized = normalizeCodeModeRequest("execute", params);
1711
+ if (normalized.domain === "hub" &&
1712
+ normalized.action === "start" &&
1713
+ /EOF|empty|parse|unexpected end/i.test(errorMsg)) {
1714
+ const serverId = String(normalized.params.server_id ?? "unknown");
1715
+ const knownKeyRequirements = {
1716
+ "brave-search": "BRAVE_API_KEY",
1717
+ github: "GITHUB_TOKEN",
1718
+ };
1719
+ const requiredKey = knownKeyRequirements[serverId];
1720
+ if (requiredKey) {
1721
+ return (errorMsg +
1722
+ `. Server '${serverId}' likely failed because ${requiredKey} is not configured. ` +
1723
+ `Set it with: sage mcp hub env set ${serverId} ${requiredKey}=<your-key>`);
1724
+ }
1725
+ return (errorMsg +
1726
+ `. Server '${serverId}' may require environment variables (API keys). ` +
1727
+ `Check with: sage mcp hub env list ${serverId}`);
1728
+ }
1729
+ }
1730
+ catch {
1731
+ // Keep original error text if normalization fails.
1732
+ }
1733
+ return errorMsg;
1734
+ }
1735
+ export function createHubListServersTool(mcpBridge) {
1736
+ return {
1737
+ name: "hub_list_servers",
1738
+ label: "Hub: List Servers",
1739
+ promptSnippet: "List Sage MCP servers directly when you need inventory/status without composing a generic sage_search call",
1740
+ promptGuidelines: [
1741
+ "Prefer hub_list_servers() for simple MCP server inventory in Pi; it is the direct convenience wrapper over domain='hub', action='list_servers'",
1742
+ "Use sage_search({ domain: 'external', action: 'list_servers', params: {} }) only when you specifically need running external servers/tools rather than the full Sage hub inventory",
1743
+ ],
1744
+ description: "List all Sage MCP servers with running/available state. This is a Pi-native convenience wrapper for sage_search({ domain:'hub', action:'list_servers', params:{} }).",
1745
+ parameters: Type.Object({}),
1746
+ renderCall: (_args, theme) => renderSageToolCall("search", { domain: "hub", action: "list_servers", params: {} }, theme),
1747
+ renderResult: (result, options, theme, context) => renderSageToolResult("search", result, options, theme, buildHubRenderContext("list_servers", {}, context)),
1748
+ execute: async () => {
1749
+ try {
1750
+ const result = await callCodeModeWithWebResilience(mcpBridge, "search", {
1751
+ domain: "hub",
1752
+ action: "list_servers",
1753
+ params: {},
1754
+ });
1755
+ return toToolResultText(result);
1756
+ }
1757
+ catch (error) {
1758
+ return {
1759
+ content: [
1760
+ {
1761
+ type: "text",
1762
+ text: `hub_list_servers failed: ${error instanceof Error ? error.message : String(error)}`,
1763
+ },
1764
+ ],
1765
+ isError: true,
1766
+ };
1767
+ }
1768
+ },
1769
+ };
1770
+ }
1771
+ export function createHubStatusTool(mcpBridge) {
1772
+ return {
1773
+ name: "hub_status",
1774
+ label: "Hub: Status",
1775
+ promptSnippet: "Inspect Sage MCP hub runtime status directly when you want running server state without composing a generic sage_search call",
1776
+ promptGuidelines: [
1777
+ "Prefer hub_status() for simple MCP runtime status in Pi; it is the direct convenience wrapper over domain='hub', action='status'",
1778
+ "Use sage_status() when you need full adapter health plus Sage context, not just hub runtime state",
1779
+ ],
1780
+ description: "Inspect Sage MCP hub runtime status. This is a Pi-native convenience wrapper for sage_search({ domain:'hub', action:'status', params:{} }).",
1781
+ parameters: Type.Object({}),
1782
+ renderCall: (_args, theme) => renderSageToolCall("search", { domain: "hub", action: "status", params: {} }, theme),
1783
+ renderResult: (result, options, theme, context) => renderSageToolResult("search", result, options, theme, buildHubRenderContext("status", {}, context)),
1784
+ execute: async () => {
1785
+ try {
1786
+ const result = await callCodeModeWithWebResilience(mcpBridge, "search", {
1787
+ domain: "hub",
1788
+ action: "status",
1789
+ params: {},
1790
+ });
1791
+ return toToolResultText(result);
1792
+ }
1793
+ catch (error) {
1794
+ return {
1795
+ content: [
1796
+ {
1797
+ type: "text",
1798
+ text: `hub_status failed: ${error instanceof Error ? error.message : String(error)}`,
1799
+ },
1800
+ ],
1801
+ isError: true,
1802
+ };
1803
+ }
1804
+ },
1805
+ };
1806
+ }
1807
+ export function createHubSessionListTool(mcpBridge) {
1808
+ return {
1809
+ name: "hub_session_list",
1810
+ label: "Hub: Session List",
1811
+ promptSnippet: "List active Sage MCP hub sessions directly when you need isolated instance visibility without composing a generic sage_search call",
1812
+ promptGuidelines: [
1813
+ "Prefer hub_session_list() for simple hub session inspection in Pi; it wraps domain='hub', action='session_list'",
1814
+ "Pass server_id when you want to filter sessions for one server, and use hub_stop_server with server_id plus session_id to stop a specific isolated session",
1815
+ ],
1816
+ description: "List active Sage MCP hub sessions, optionally filtered by server_id. This is a Pi-native convenience wrapper for sage_search({ domain:'hub', action:'session_list', params:{ server_id? } }).",
1817
+ parameters: Type.Object({
1818
+ server_id: Type.Optional(Type.String({
1819
+ description: "Optional hub server identifier to filter sessions",
1820
+ })),
1821
+ }),
1822
+ renderCall: (args, theme) => renderSageToolCall("search", {
1823
+ domain: "hub",
1824
+ action: "session_list",
1825
+ params: normalizeParams(args),
1826
+ }, theme),
1827
+ renderResult: (result, options, theme, context) => renderSageToolResult("search", result, options, theme, buildHubRenderContext("session_list", normalizeParams(context?.args), context)),
1828
+ execute: async (_toolCallId, params) => {
1829
+ try {
1830
+ const payload = normalizeParams(params);
1831
+ const result = await callCodeModeWithWebResilience(mcpBridge, "search", {
1832
+ domain: "hub",
1833
+ action: "session_list",
1834
+ params: payload,
1835
+ });
1836
+ return toToolResultText(result);
1837
+ }
1838
+ catch (error) {
1839
+ return {
1840
+ content: [
1841
+ {
1842
+ type: "text",
1843
+ text: `hub_session_list failed: ${error instanceof Error ? error.message : String(error)}`,
1844
+ },
1845
+ ],
1846
+ isError: true,
1847
+ };
1848
+ }
1849
+ },
1850
+ };
1851
+ }
1852
+ export function createHubStopServerTool(mcpBridge) {
1853
+ return {
1854
+ name: "hub_stop_server",
1855
+ label: "Hub: Stop Server",
1856
+ promptSnippet: "Stop a Sage MCP server directly when you know the server_id and want to end its runtime cleanly",
1857
+ promptGuidelines: [
1858
+ "Prefer hub_stop_server({ server_id: '...' }) for simple MCP server shutdown in Pi; it wraps domain='hub', action='stop'",
1859
+ "Use hub_status() or hub_list_servers() after stopping a server if you need to confirm the resulting hub state",
1860
+ ],
1861
+ description: "Stop a Sage MCP server by server_id. This is a Pi-native convenience wrapper for sage_execute({ domain:'hub', action:'stop', params:{ server_id:'...' } }).",
1862
+ parameters: Type.Object({
1863
+ server_id: Type.String({
1864
+ description: "Hub server identifier to stop, such as github or brave-search",
1865
+ }),
1866
+ session_id: Type.Optional(Type.String({
1867
+ description: "Optional hub session identifier for isolated instances",
1868
+ })),
1869
+ }),
1870
+ renderCall: (args, theme) => renderSageToolCall("execute", { domain: "hub", action: "stop", params: normalizeParams(args) }, theme),
1871
+ renderResult: (result, options, theme, context) => renderSageToolResult("execute", result, options, theme, buildHubRenderContext("stop", normalizeParams(context?.args), context)),
1872
+ execute: async (_toolCallId, params) => {
1873
+ try {
1874
+ const payload = normalizeParams(params);
1875
+ const result = await callCodeModeWithWebResilience(mcpBridge, "execute", {
1876
+ domain: "hub",
1877
+ action: "stop",
1878
+ params: payload,
1879
+ });
1880
+ return toToolResultText(result);
1881
+ }
1882
+ catch (error) {
1883
+ return {
1884
+ content: [
1885
+ {
1886
+ type: "text",
1887
+ text: `hub_stop_server failed: ${error instanceof Error ? error.message : String(error)}`,
1888
+ },
1889
+ ],
1890
+ isError: true,
1891
+ };
1892
+ }
1893
+ },
1894
+ };
1895
+ }
1896
+ export function createHubStartServerTool(mcpBridge) {
1897
+ return {
1898
+ name: "hub_start_server",
1899
+ label: "Hub: Start Server",
1900
+ promptSnippet: "Start a Sage MCP server directly when you already know the server_id and want Pi to expose its tools",
1901
+ promptGuidelines: [
1902
+ "Prefer hub_start_server({ server_id: '...' }) for simple MCP server startup in Pi; it wraps domain='hub', action='start'",
1903
+ "After starting an external MCP server, call its tools through sage_execute/sage_search with domain='external' and params { tool_name, tool_params }",
1904
+ ],
1905
+ description: "Start a Sage MCP server by server_id. This is a Pi-native convenience wrapper for sage_execute({ domain:'hub', action:'start', params:{ server_id:'...' } }).",
1906
+ parameters: Type.Object({
1907
+ server_id: Type.String({
1908
+ description: "Hub server identifier to start, such as github or brave-search",
1909
+ }),
1910
+ session_id: Type.Optional(Type.String({
1911
+ description: "Optional hub session identifier for isolated instances",
1912
+ })),
1913
+ }),
1914
+ renderCall: (args, theme) => renderSageToolCall("execute", { domain: "hub", action: "start", params: normalizeParams(args) }, theme),
1915
+ renderResult: (result, options, theme, context) => renderSageToolResult("execute", result, options, theme, buildHubRenderContext("start", normalizeParams(context?.args), context)),
1916
+ execute: async (_toolCallId, params) => {
1917
+ try {
1918
+ const payload = normalizeParams(params);
1919
+ const result = await callCodeModeWithWebResilience(mcpBridge, "execute", {
1920
+ domain: "hub",
1921
+ action: "start",
1922
+ params: payload,
1923
+ });
1924
+ return toToolResultText(result);
1925
+ }
1926
+ catch (error) {
1927
+ const errorMsg = augmentHubStartErrorMessage({ domain: "hub", action: "start", params }, error instanceof Error ? error.message : String(error));
1928
+ return {
1929
+ content: [
1930
+ { type: "text", text: `hub_start_server failed: ${errorMsg}` },
1931
+ ],
1932
+ isError: true,
1933
+ };
1934
+ }
1935
+ },
1936
+ };
1937
+ }
1378
1938
  export function createSageStatusTool(mcpBridge) {
1379
1939
  return {
1380
1940
  name: "sage_status",
@@ -1480,7 +2040,11 @@ export function wrapToolsWithSageSecurity(tools, options) {
1480
2040
  text: `Sage security blocked ${tool.name}: ${pre.reason ?? "high-risk payload detected"}`,
1481
2041
  },
1482
2042
  ],
1483
- details: { blockedBySageSecurity: true, reason: pre.reason, tool: tool.name },
2043
+ details: {
2044
+ blockedBySageSecurity: true,
2045
+ reason: pre.reason,
2046
+ tool: tool.name,
2047
+ },
1484
2048
  isError: true,
1485
2049
  };
1486
2050
  }
@@ -1520,6 +2084,11 @@ export function createSageExtensions(options) {
1520
2084
  console.error("[sage-pi-adapter] failed to start MCP bridge:", error);
1521
2085
  });
1522
2086
  const sageTools = [
2087
+ createHubListServersTool(mcpBridge),
2088
+ createHubStatusTool(mcpBridge),
2089
+ createHubSessionListTool(mcpBridge),
2090
+ createHubStartServerTool(mcpBridge),
2091
+ createHubStopServerTool(mcpBridge),
1523
2092
  createSageSearchTool(mcpBridge),
1524
2093
  createSageExecuteTool(mcpBridge),
1525
2094
  createSageStatusTool(mcpBridge),
@@ -1635,7 +2204,9 @@ export async function createSageSessionConfig(config = {}) {
1635
2204
  const content = parsed.content.trim();
1636
2205
  if (!content)
1637
2206
  return;
1638
- void hookRuntime.capturePrompt(content, activeSessionId).catch((error) => {
2207
+ void hookRuntime
2208
+ .capturePrompt(content, activeSessionId)
2209
+ .catch((error) => {
1639
2210
  console.error("[sage-pi-adapter] prompt capture failed:", error);
1640
2211
  });
1641
2212
  void extension.sageInjectSuggestion(content, session, activeSessionId);
@@ -1646,7 +2217,9 @@ export async function createSageSessionConfig(config = {}) {
1646
2217
  if (!response)
1647
2218
  return;
1648
2219
  const usage = extractUsage(parsed.usage);
1649
- void hookRuntime.captureResponse(response, usage, activeSessionId).catch((error) => {
2220
+ void hookRuntime
2221
+ .captureResponse(response, usage, activeSessionId)
2222
+ .catch((error) => {
1650
2223
  console.error("[sage-pi-adapter] response capture failed:", error);
1651
2224
  });
1652
2225
  }