@sage-protocol/pi-adapter 0.4.3 → 0.5.1
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/README.md +21 -0
- package/USAGE.md +21 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6723 -51
- package/dist/intercom-storage.d.ts +20 -0
- package/dist/intercom-storage.d.ts.map +1 -0
- package/dist/intercom.d.ts +72 -0
- package/dist/intercom.d.ts.map +1 -0
- package/dist/sage-pi-adapter.d.ts +13 -0
- package/dist/sage-pi-adapter.d.ts.map +1 -1
- package/dist/wallet-status.d.ts +37 -0
- package/dist/wallet-status.d.ts.map +1 -0
- package/extensions/index.ts +27 -1
- package/package.json +8 -7
- package/dist/behavior/index.js +0 -9
- package/dist/behavior/index.js.map +0 -1
- package/dist/behavior/pi-executor.js +0 -188
- package/dist/behavior/pi-executor.js.map +0 -1
- package/dist/behavior/resolver.js +0 -171
- package/dist/behavior/resolver.js.map +0 -1
- package/dist/behavior/types.js +0 -16
- package/dist/behavior/types.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/sage-pi-adapter.js +0 -2862
- package/dist/sage-pi-adapter.js.map +0 -1
package/dist/sage-pi-adapter.js
DELETED
|
@@ -1,2862 +0,0 @@
|
|
|
1
|
-
import { Type } from "@sinclair/typebox";
|
|
2
|
-
import { spawn } from "node:child_process";
|
|
3
|
-
import { randomUUID } from "node:crypto";
|
|
4
|
-
import { createRequire } from "node:module";
|
|
5
|
-
const require = createRequire(import.meta.url);
|
|
6
|
-
// ── Unicode glyph fallback ──
|
|
7
|
-
// Some terminals lack glyph support for arrows, checkmarks, and dotted separators.
|
|
8
|
-
// Set SAGE_NO_UNICODE=1 to force ASCII-safe output in widget, summary, and quality icons.
|
|
9
|
-
// Even in Unicode mode, we use → (U+2192) instead of ↳ (U+21B3) for wider terminal support.
|
|
10
|
-
const GLYPH_FALLBACK = {
|
|
11
|
-
"\u00B7": "|", // Middle Dot
|
|
12
|
-
"\u2192": ">", // Rightwards Arrow (replaces ↳)
|
|
13
|
-
"\u21B3": ">", // Downwards Arrow with Tip Rightwards (legacy)
|
|
14
|
-
"\u2713": "[+]", // Check Mark
|
|
15
|
-
"\u26A0": "[!]", // Warning Sign
|
|
16
|
-
"\u2717": "[-]", // Ballot X
|
|
17
|
-
"\u25CF": "*", // Black Circle (status: healthy)
|
|
18
|
-
"\u25D0": "~", // Circle with Left Half Black (status: degraded)
|
|
19
|
-
"\u25CB": "o", // White Circle (status: offline)
|
|
20
|
-
"\u2026": "...", // Horizontal Ellipsis (activity hint)
|
|
21
|
-
};
|
|
22
|
-
function isAsciiMode() {
|
|
23
|
-
const env = process.env.SAGE_NO_UNICODE;
|
|
24
|
-
return env === "1" || env === "true" || env === "yes";
|
|
25
|
-
}
|
|
26
|
-
export function unicodeSafe(text) {
|
|
27
|
-
if (!isAsciiMode())
|
|
28
|
-
return text;
|
|
29
|
-
let out = "";
|
|
30
|
-
for (const ch of text) {
|
|
31
|
-
out += GLYPH_FALLBACK[ch] ?? ch;
|
|
32
|
-
}
|
|
33
|
-
return out;
|
|
34
|
-
}
|
|
35
|
-
const SAGE_ENV_PASSTHROUGH = [
|
|
36
|
-
"SAGE_FILTER_EXECUTION",
|
|
37
|
-
"SAGE_SKILL_USED",
|
|
38
|
-
"SAGE_SKILL_OUTCOME",
|
|
39
|
-
"SAGE_SOURCE",
|
|
40
|
-
"SAGE_SESSION_ID",
|
|
41
|
-
];
|
|
42
|
-
function sagePassthroughEnv() {
|
|
43
|
-
const env = {};
|
|
44
|
-
for (const key of SAGE_ENV_PASSTHROUGH) {
|
|
45
|
-
const value = process.env[key];
|
|
46
|
-
if (value !== undefined)
|
|
47
|
-
env[key] = value;
|
|
48
|
-
}
|
|
49
|
-
return env;
|
|
50
|
-
}
|
|
51
|
-
const SAGE_COMMAND_TIMEOUT_KILL_GRACE_MS = 500;
|
|
52
|
-
const SAGE_SOFT_COMMAND_LOG_COOLDOWN_MS = 60_000;
|
|
53
|
-
const SAGE_HOOK_TIMEOUTS = {
|
|
54
|
-
sessionContext: 4_000,
|
|
55
|
-
skillVisibility: 2_500,
|
|
56
|
-
suggest: 4_000,
|
|
57
|
-
capture: 1_500,
|
|
58
|
-
feedback: 1_500,
|
|
59
|
-
securityScan: 4_000,
|
|
60
|
-
resolveSkill: 4_000,
|
|
61
|
-
quality: 3_000,
|
|
62
|
-
};
|
|
63
|
-
function formatSageCommand(command, args) {
|
|
64
|
-
const quoted = [command, ...args].map((part) => {
|
|
65
|
-
if (!part)
|
|
66
|
-
return "''";
|
|
67
|
-
return /[\s"'`$\\]/.test(part) ? JSON.stringify(part) : part;
|
|
68
|
-
});
|
|
69
|
-
return quoted.join(" ");
|
|
70
|
-
}
|
|
71
|
-
const CODE_MODE_SEARCH_TOOLS = ["sage_search", "sage_code_sage_search"];
|
|
72
|
-
const CODE_MODE_EXECUTE_TOOLS = ["sage_execute", "sage_code_sage_execute"];
|
|
73
|
-
const SEARCH_ACTION_ALIASES = {
|
|
74
|
-
skills: {
|
|
75
|
-
search_skills: "search",
|
|
76
|
-
list_skills: "list",
|
|
77
|
-
get_skill: "get",
|
|
78
|
-
use_skill: "use",
|
|
79
|
-
sync_skills: "sync",
|
|
80
|
-
},
|
|
81
|
-
prompts: {
|
|
82
|
-
search_prompts: "search",
|
|
83
|
-
list_prompts: "list",
|
|
84
|
-
get_prompt: "get",
|
|
85
|
-
},
|
|
86
|
-
external: {
|
|
87
|
-
list_external_servers: "list_servers",
|
|
88
|
-
search_external: "search",
|
|
89
|
-
},
|
|
90
|
-
hub: {
|
|
91
|
-
hub_list_servers: "list_servers",
|
|
92
|
-
hub_status: "status",
|
|
93
|
-
hub_session_list: "session_list",
|
|
94
|
-
},
|
|
95
|
-
};
|
|
96
|
-
const EXECUTE_ACTION_ALIASES = {
|
|
97
|
-
skills: {
|
|
98
|
-
use_skill: "use",
|
|
99
|
-
sync_skills: "sync",
|
|
100
|
-
},
|
|
101
|
-
library: {
|
|
102
|
-
skill_add: "skill_add",
|
|
103
|
-
skill_remove: "skill_remove",
|
|
104
|
-
skill_move: "skill_move",
|
|
105
|
-
},
|
|
106
|
-
external: {
|
|
107
|
-
execute_external: "execute",
|
|
108
|
-
call_external: "call",
|
|
109
|
-
},
|
|
110
|
-
hub: {
|
|
111
|
-
hub_start_server: "start",
|
|
112
|
-
hub_stop_server: "stop",
|
|
113
|
-
},
|
|
114
|
-
};
|
|
115
|
-
const EXTERNAL_SEARCH_ACTIONS = new Set(["list_servers", "search"]);
|
|
116
|
-
const EXTERNAL_EXECUTE_ACTIONS = new Set(["execute", "call"]);
|
|
117
|
-
const EXTERNAL_TOOL_ALIASES = {
|
|
118
|
-
web_search: "duckduckgo_web_search",
|
|
119
|
-
};
|
|
120
|
-
const WEB_SEARCH_PROVIDER_TOOLS = new Set([
|
|
121
|
-
"duckduckgo_web_search",
|
|
122
|
-
"brave_web_search",
|
|
123
|
-
]);
|
|
124
|
-
const WEB_SEARCH_RETRY_PATTERNS = [
|
|
125
|
-
/ddg detected an anomaly/i,
|
|
126
|
-
/too quickly/i,
|
|
127
|
-
/rate\s*limit/i,
|
|
128
|
-
/\b429\b/,
|
|
129
|
-
/temporarily unavailable/i,
|
|
130
|
-
/timed out/i,
|
|
131
|
-
/timeout/i,
|
|
132
|
-
/socket hang up/i,
|
|
133
|
-
/econnreset/i,
|
|
134
|
-
/network error/i,
|
|
135
|
-
];
|
|
136
|
-
const WEB_SEARCH_MAX_ATTEMPTS_PER_PROVIDER = 2;
|
|
137
|
-
const WEB_SEARCH_MIN_INTERVAL_MS = 250;
|
|
138
|
-
const WEB_SEARCH_RETRY_BASE_MS = 250;
|
|
139
|
-
const LAST_WEB_SEARCH_CALL_BY_PROVIDER = new Map();
|
|
140
|
-
const DAEMON_IPC_MAX_ATTEMPTS = Math.max(1, Number.parseInt(process.env.SAGE_DAEMON_IPC_MAX_ATTEMPTS || "3", 10) || 3);
|
|
141
|
-
const DAEMON_IPC_RETRY_BASE_MS = Math.max(50, Number.parseInt(process.env.SAGE_DAEMON_IPC_RETRY_BASE_MS || "350", 10) ||
|
|
142
|
-
350);
|
|
143
|
-
const DOMAINLESS_ACTION_DOMAIN_HINTS = {
|
|
144
|
-
search: "prompts",
|
|
145
|
-
list: "prompts",
|
|
146
|
-
get: "prompts",
|
|
147
|
-
search_prompts: "prompts",
|
|
148
|
-
list_prompts: "prompts",
|
|
149
|
-
get_prompt: "prompts",
|
|
150
|
-
use: "skills",
|
|
151
|
-
sync: "skills",
|
|
152
|
-
search_skills: "skills",
|
|
153
|
-
list_skills: "skills",
|
|
154
|
-
get_skill: "skills",
|
|
155
|
-
use_skill: "skills",
|
|
156
|
-
sync_skills: "skills",
|
|
157
|
-
recommend: "builder",
|
|
158
|
-
suggest: "meta",
|
|
159
|
-
skill_add: "library",
|
|
160
|
-
skill_remove: "library",
|
|
161
|
-
skill_move: "library",
|
|
162
|
-
status: "hub",
|
|
163
|
-
list_servers: "hub",
|
|
164
|
-
session_list: "hub",
|
|
165
|
-
start: "hub",
|
|
166
|
-
stop: "hub",
|
|
167
|
-
hub_list_servers: "hub",
|
|
168
|
-
hub_status: "hub",
|
|
169
|
-
hub_session_list: "hub",
|
|
170
|
-
hub_start_server: "hub",
|
|
171
|
-
hub_stop_server: "hub",
|
|
172
|
-
search_external: "external",
|
|
173
|
-
list_external_servers: "external",
|
|
174
|
-
execute_external: "external",
|
|
175
|
-
call_external: "external",
|
|
176
|
-
};
|
|
177
|
-
const DEFAULT_SEARCH_ACTION_BY_DOMAIN = {
|
|
178
|
-
prompts: "list",
|
|
179
|
-
behaviors: "recommend",
|
|
180
|
-
skills: "list",
|
|
181
|
-
builder: "recommend",
|
|
182
|
-
governance: "list_subdaos",
|
|
183
|
-
chat: "list_rooms",
|
|
184
|
-
social: "following",
|
|
185
|
-
rlm: "stats",
|
|
186
|
-
library_sync: "sync",
|
|
187
|
-
security: "scan",
|
|
188
|
-
meta: "get_project_context",
|
|
189
|
-
hub: "status",
|
|
190
|
-
activation: "active",
|
|
191
|
-
wallet: "balance",
|
|
192
|
-
library: "get_active",
|
|
193
|
-
help: "list",
|
|
194
|
-
external: "list_servers",
|
|
195
|
-
};
|
|
196
|
-
const DOMAINLESS_ACTIONS = new Set(Object.keys(DOMAINLESS_ACTION_DOMAIN_HINTS));
|
|
197
|
-
function mapExternalToolAlias(toolName) {
|
|
198
|
-
const normalized = toolName.trim().toLowerCase();
|
|
199
|
-
if (normalized === "web_search") {
|
|
200
|
-
return hasBraveApiKey() ? "brave_web_search" : "duckduckgo_web_search";
|
|
201
|
-
}
|
|
202
|
-
return EXTERNAL_TOOL_ALIASES[normalized] ?? normalized;
|
|
203
|
-
}
|
|
204
|
-
/**
|
|
205
|
-
* Check whether the Brave API key is likely available.
|
|
206
|
-
* We check both the local process environment AND assume availability when
|
|
207
|
-
* the brave-search hub server has been configured (its env vars are injected
|
|
208
|
-
* by the Sage daemon at server spawn time, not exposed to this process).
|
|
209
|
-
* The resilience layer will naturally skip Brave if the call actually fails,
|
|
210
|
-
* so a false-positive here is harmless.
|
|
211
|
-
*/
|
|
212
|
-
function hasBraveApiKey() {
|
|
213
|
-
// Always return true -- Brave should be tried as a fallback provider.
|
|
214
|
-
// The key lives in Sage hub env (injected at server spawn time), not in
|
|
215
|
-
// this process's env, so process.env.BRAVE_API_KEY is almost never set.
|
|
216
|
-
// If Brave isn't configured, the MCP call will fail and the resilience
|
|
217
|
-
// layer moves to the next provider.
|
|
218
|
-
return true;
|
|
219
|
-
}
|
|
220
|
-
function toErrorMessage(error) {
|
|
221
|
-
if (error instanceof Error)
|
|
222
|
-
return error.message;
|
|
223
|
-
return String(error);
|
|
224
|
-
}
|
|
225
|
-
function isTransientWebSearchError(error) {
|
|
226
|
-
const msg = toErrorMessage(error);
|
|
227
|
-
return WEB_SEARCH_RETRY_PATTERNS.some((pattern) => pattern.test(msg));
|
|
228
|
-
}
|
|
229
|
-
function isTransientDaemonIpcError(error) {
|
|
230
|
-
const msg = toErrorMessage(error).toLowerCase();
|
|
231
|
-
return (msg.includes("daemon ipc failed") ||
|
|
232
|
-
msg.includes("resource temporarily unavailable") ||
|
|
233
|
-
msg.includes("os error 35") ||
|
|
234
|
-
msg.includes("eagain") ||
|
|
235
|
-
msg.includes("temporarily unavailable"));
|
|
236
|
-
}
|
|
237
|
-
function getExternalWebSearchTool(request) {
|
|
238
|
-
if (request.domain !== "external")
|
|
239
|
-
return "";
|
|
240
|
-
if (request.action === "list_servers")
|
|
241
|
-
return "";
|
|
242
|
-
const toolName = request.params.tool_name;
|
|
243
|
-
if (typeof toolName !== "string")
|
|
244
|
-
return "";
|
|
245
|
-
return toolName.trim().toLowerCase();
|
|
246
|
-
}
|
|
247
|
-
function webSearchProviderOrder(requestedTool) {
|
|
248
|
-
const order = [];
|
|
249
|
-
const add = (provider) => {
|
|
250
|
-
if (!provider || order.includes(provider))
|
|
251
|
-
return;
|
|
252
|
-
order.push(provider);
|
|
253
|
-
};
|
|
254
|
-
// If a specific provider was requested, put it first with the other as fallback
|
|
255
|
-
if (requestedTool === "brave_web_search") {
|
|
256
|
-
add("brave_web_search");
|
|
257
|
-
add("duckduckgo_web_search");
|
|
258
|
-
return order;
|
|
259
|
-
}
|
|
260
|
-
if (requestedTool === "duckduckgo_web_search") {
|
|
261
|
-
add("duckduckgo_web_search");
|
|
262
|
-
add("brave_web_search");
|
|
263
|
-
return order;
|
|
264
|
-
}
|
|
265
|
-
// Default: Brave first (more reliable, no rate limiting), DuckDuckGo as fallback
|
|
266
|
-
add("brave_web_search");
|
|
267
|
-
add("duckduckgo_web_search");
|
|
268
|
-
return order;
|
|
269
|
-
}
|
|
270
|
-
async function wait(ms) {
|
|
271
|
-
if (ms <= 0)
|
|
272
|
-
return;
|
|
273
|
-
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
274
|
-
}
|
|
275
|
-
async function enforceWebSearchPacing(provider) {
|
|
276
|
-
const now = Date.now();
|
|
277
|
-
const last = LAST_WEB_SEARCH_CALL_BY_PROVIDER.get(provider) ?? 0;
|
|
278
|
-
const elapsed = now - last;
|
|
279
|
-
if (elapsed < WEB_SEARCH_MIN_INTERVAL_MS) {
|
|
280
|
-
await wait(WEB_SEARCH_MIN_INTERVAL_MS - elapsed);
|
|
281
|
-
}
|
|
282
|
-
LAST_WEB_SEARCH_CALL_BY_PROVIDER.set(provider, Date.now());
|
|
283
|
-
}
|
|
284
|
-
function retryBackoffMs(attempt) {
|
|
285
|
-
const jitter = Math.floor(Math.random() * 200);
|
|
286
|
-
return WEB_SEARCH_RETRY_BASE_MS * 2 ** Math.max(attempt - 1, 0) + jitter;
|
|
287
|
-
}
|
|
288
|
-
function daemonRetryBackoffMs(attempt) {
|
|
289
|
-
const jitter = Math.floor(Math.random() * 150);
|
|
290
|
-
return DAEMON_IPC_RETRY_BASE_MS * 2 ** Math.max(attempt - 1, 0) + jitter;
|
|
291
|
-
}
|
|
292
|
-
async function callWithDaemonRetry(call, args) {
|
|
293
|
-
let lastError;
|
|
294
|
-
for (let attempt = 1; attempt <= DAEMON_IPC_MAX_ATTEMPTS; attempt += 1) {
|
|
295
|
-
try {
|
|
296
|
-
return await call(args);
|
|
297
|
-
}
|
|
298
|
-
catch (error) {
|
|
299
|
-
lastError = error;
|
|
300
|
-
const retryable = isTransientDaemonIpcError(error);
|
|
301
|
-
if (!retryable || attempt >= DAEMON_IPC_MAX_ATTEMPTS)
|
|
302
|
-
throw error;
|
|
303
|
-
await wait(daemonRetryBackoffMs(attempt));
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
throw lastError instanceof Error
|
|
307
|
-
? lastError
|
|
308
|
-
: new Error(String(lastError ?? "unknown daemon IPC failure"));
|
|
309
|
-
}
|
|
310
|
-
function withExternalToolName(request, toolName) {
|
|
311
|
-
return {
|
|
312
|
-
...request,
|
|
313
|
-
params: {
|
|
314
|
-
...request.params,
|
|
315
|
-
tool_name: toolName,
|
|
316
|
-
},
|
|
317
|
-
};
|
|
318
|
-
}
|
|
319
|
-
async function callCodeModeWithWebResilience(mcpBridge, mode, request) {
|
|
320
|
-
const call = mode === "search"
|
|
321
|
-
? (args) => mcpBridge.callCodeModeSearch(args)
|
|
322
|
-
: (args) => mcpBridge.callCodeModeExecute(args);
|
|
323
|
-
const requestedTool = getExternalWebSearchTool(request);
|
|
324
|
-
if (!WEB_SEARCH_PROVIDER_TOOLS.has(requestedTool)) {
|
|
325
|
-
return callWithDaemonRetry(call, request);
|
|
326
|
-
}
|
|
327
|
-
const providers = webSearchProviderOrder(requestedTool);
|
|
328
|
-
const failures = [];
|
|
329
|
-
for (const provider of providers) {
|
|
330
|
-
const providerRequest = withExternalToolName(request, provider);
|
|
331
|
-
for (let attempt = 1; attempt <= WEB_SEARCH_MAX_ATTEMPTS_PER_PROVIDER; attempt += 1) {
|
|
332
|
-
try {
|
|
333
|
-
await enforceWebSearchPacing(provider);
|
|
334
|
-
return await callWithDaemonRetry(call, providerRequest);
|
|
335
|
-
}
|
|
336
|
-
catch (error) {
|
|
337
|
-
failures.push(`${provider} (attempt ${attempt}): ${toErrorMessage(error)}`);
|
|
338
|
-
const shouldRetry = isTransientWebSearchError(error) &&
|
|
339
|
-
attempt < WEB_SEARCH_MAX_ATTEMPTS_PER_PROVIDER;
|
|
340
|
-
if (shouldRetry) {
|
|
341
|
-
await wait(retryBackoffMs(attempt));
|
|
342
|
-
continue;
|
|
343
|
-
}
|
|
344
|
-
break;
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
throw new Error(`Web search failed after trying providers. ${failures.join(" | ")}`);
|
|
349
|
-
}
|
|
350
|
-
function normalizeParams(input) {
|
|
351
|
-
return input && typeof input === "object" && !Array.isArray(input)
|
|
352
|
-
? input
|
|
353
|
-
: {};
|
|
354
|
-
}
|
|
355
|
-
function looksLikeExternalToolAction(action) {
|
|
356
|
-
if (!action)
|
|
357
|
-
return false;
|
|
358
|
-
if (EXTERNAL_TOOL_ALIASES[action])
|
|
359
|
-
return true;
|
|
360
|
-
if (DOMAINLESS_ACTIONS.has(action))
|
|
361
|
-
return false;
|
|
362
|
-
return action.includes("_");
|
|
363
|
-
}
|
|
364
|
-
function normalizeCodeModeRequest(mode, args) {
|
|
365
|
-
const rawDomain = String(args.domain ?? "")
|
|
366
|
-
.trim()
|
|
367
|
-
.toLowerCase();
|
|
368
|
-
const rawAction = String(args.action ?? "")
|
|
369
|
-
.trim()
|
|
370
|
-
.toLowerCase();
|
|
371
|
-
const payload = normalizeParams(args.params);
|
|
372
|
-
if (!rawAction) {
|
|
373
|
-
if (mode === "search") {
|
|
374
|
-
const fallbackDomain = rawDomain || "help";
|
|
375
|
-
const fallbackAction = DEFAULT_SEARCH_ACTION_BY_DOMAIN[fallbackDomain] ||
|
|
376
|
-
DEFAULT_SEARCH_ACTION_BY_DOMAIN.help;
|
|
377
|
-
return {
|
|
378
|
-
domain: fallbackDomain,
|
|
379
|
-
action: fallbackAction,
|
|
380
|
-
params: payload,
|
|
381
|
-
};
|
|
382
|
-
}
|
|
383
|
-
throw new Error("Missing required parameter 'action'");
|
|
384
|
-
}
|
|
385
|
-
if (!rawDomain && looksLikeExternalToolAction(rawAction)) {
|
|
386
|
-
return {
|
|
387
|
-
domain: "external",
|
|
388
|
-
action: mode === "search" ? "search" : "call",
|
|
389
|
-
params: {
|
|
390
|
-
tool_name: mapExternalToolAlias(rawAction),
|
|
391
|
-
tool_params: payload,
|
|
392
|
-
},
|
|
393
|
-
};
|
|
394
|
-
}
|
|
395
|
-
const domain = rawDomain || DOMAINLESS_ACTION_DOMAIN_HINTS[rawAction] || "";
|
|
396
|
-
if (!domain) {
|
|
397
|
-
throw new Error("Missing required parameter 'domain'. Include a Sage domain like prompts, skills, or external.");
|
|
398
|
-
}
|
|
399
|
-
const action = normalizeAction(mode === "search" ? SEARCH_ACTION_ALIASES : EXECUTE_ACTION_ALIASES, domain, rawAction);
|
|
400
|
-
if (domain === "external") {
|
|
401
|
-
const allowedActions = mode === "search" ? EXTERNAL_SEARCH_ACTIONS : EXTERNAL_EXECUTE_ACTIONS;
|
|
402
|
-
if (!allowedActions.has(action)) {
|
|
403
|
-
return {
|
|
404
|
-
domain,
|
|
405
|
-
action: mode === "search" ? "search" : "call",
|
|
406
|
-
params: {
|
|
407
|
-
tool_name: mapExternalToolAlias(action),
|
|
408
|
-
tool_params: payload,
|
|
409
|
-
},
|
|
410
|
-
};
|
|
411
|
-
}
|
|
412
|
-
if (action !== "list_servers" &&
|
|
413
|
-
typeof payload.tool_name === "string" &&
|
|
414
|
-
payload.tool_name.trim()) {
|
|
415
|
-
return {
|
|
416
|
-
domain,
|
|
417
|
-
action,
|
|
418
|
-
params: {
|
|
419
|
-
...payload,
|
|
420
|
-
tool_name: mapExternalToolAlias(payload.tool_name),
|
|
421
|
-
},
|
|
422
|
-
};
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
return { domain, action, params: payload };
|
|
426
|
-
}
|
|
427
|
-
function normalizeAction(aliasesByDomain, domain, action) {
|
|
428
|
-
const normalizedDomain = domain.trim().toLowerCase();
|
|
429
|
-
const normalizedAction = action.trim().toLowerCase();
|
|
430
|
-
if (!normalizedAction)
|
|
431
|
-
return "";
|
|
432
|
-
const domainAliases = aliasesByDomain[normalizedDomain];
|
|
433
|
-
if (!domainAliases)
|
|
434
|
-
return normalizedAction;
|
|
435
|
-
return domainAliases[normalizedAction] ?? normalizedAction;
|
|
436
|
-
}
|
|
437
|
-
export async function defaultSageCommandRunner(request) {
|
|
438
|
-
return new Promise((resolve, reject) => {
|
|
439
|
-
const proc = spawn(request.command, request.args, {
|
|
440
|
-
cwd: request.cwd,
|
|
441
|
-
env: { ...process.env, ...request.env },
|
|
442
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
443
|
-
});
|
|
444
|
-
let stdout = "";
|
|
445
|
-
let stderr = "";
|
|
446
|
-
let timedOut = false;
|
|
447
|
-
let settled = false;
|
|
448
|
-
let killEscalation = null;
|
|
449
|
-
const timeout = setTimeout(() => {
|
|
450
|
-
timedOut = true;
|
|
451
|
-
proc.kill("SIGTERM");
|
|
452
|
-
killEscalation = setTimeout(() => {
|
|
453
|
-
if (settled)
|
|
454
|
-
return;
|
|
455
|
-
proc.kill("SIGKILL");
|
|
456
|
-
}, SAGE_COMMAND_TIMEOUT_KILL_GRACE_MS);
|
|
457
|
-
}, request.timeoutMs);
|
|
458
|
-
const finish = (result) => {
|
|
459
|
-
if (settled)
|
|
460
|
-
return;
|
|
461
|
-
settled = true;
|
|
462
|
-
clearTimeout(timeout);
|
|
463
|
-
if (killEscalation)
|
|
464
|
-
clearTimeout(killEscalation);
|
|
465
|
-
resolve(result);
|
|
466
|
-
};
|
|
467
|
-
const fail = (err) => {
|
|
468
|
-
if (settled)
|
|
469
|
-
return;
|
|
470
|
-
settled = true;
|
|
471
|
-
clearTimeout(timeout);
|
|
472
|
-
if (killEscalation)
|
|
473
|
-
clearTimeout(killEscalation);
|
|
474
|
-
reject(err);
|
|
475
|
-
};
|
|
476
|
-
proc.on("error", (err) => {
|
|
477
|
-
fail(err);
|
|
478
|
-
});
|
|
479
|
-
proc.stdin.on("error", (err) => {
|
|
480
|
-
fail(err instanceof Error ? err : new Error(String(err)));
|
|
481
|
-
});
|
|
482
|
-
proc.stdout.setEncoding("utf8");
|
|
483
|
-
proc.stderr.setEncoding("utf8");
|
|
484
|
-
proc.stdout.on("data", (chunk) => {
|
|
485
|
-
stdout += chunk;
|
|
486
|
-
});
|
|
487
|
-
proc.stderr.on("data", (chunk) => {
|
|
488
|
-
stderr += chunk;
|
|
489
|
-
});
|
|
490
|
-
proc.on("close", (code) => {
|
|
491
|
-
finish({
|
|
492
|
-
exitCode: code ?? -1,
|
|
493
|
-
stdout,
|
|
494
|
-
stderr,
|
|
495
|
-
timedOut,
|
|
496
|
-
});
|
|
497
|
-
});
|
|
498
|
-
if (!request.stdin) {
|
|
499
|
-
proc.stdin.end();
|
|
500
|
-
return;
|
|
501
|
-
}
|
|
502
|
-
if (!proc.stdin.writable || proc.stdin.destroyed) {
|
|
503
|
-
proc.stdin.end();
|
|
504
|
-
return;
|
|
505
|
-
}
|
|
506
|
-
proc.stdin.end(request.stdin, (err) => {
|
|
507
|
-
if (!err)
|
|
508
|
-
return;
|
|
509
|
-
fail(err instanceof Error ? err : new Error(String(err)));
|
|
510
|
-
});
|
|
511
|
-
});
|
|
512
|
-
}
|
|
513
|
-
function extractText(input) {
|
|
514
|
-
if (typeof input === "string")
|
|
515
|
-
return input;
|
|
516
|
-
if (Array.isArray(input)) {
|
|
517
|
-
return input
|
|
518
|
-
.map((block) => {
|
|
519
|
-
if (!block || typeof block !== "object")
|
|
520
|
-
return "";
|
|
521
|
-
const text = block.text;
|
|
522
|
-
return typeof text === "string" ? text : "";
|
|
523
|
-
})
|
|
524
|
-
.filter(Boolean)
|
|
525
|
-
.join("\n")
|
|
526
|
-
.trim();
|
|
527
|
-
}
|
|
528
|
-
if (!input || typeof input !== "object")
|
|
529
|
-
return "";
|
|
530
|
-
const obj = input;
|
|
531
|
-
const direct = [obj.text, obj.content, obj.prompt, obj.message, obj.input];
|
|
532
|
-
for (const candidate of direct) {
|
|
533
|
-
if (typeof candidate === "string" && candidate.trim())
|
|
534
|
-
return candidate.trim();
|
|
535
|
-
}
|
|
536
|
-
if (Array.isArray(obj.content)) {
|
|
537
|
-
const fromBlocks = obj.content
|
|
538
|
-
.map((block) => {
|
|
539
|
-
if (!block || typeof block !== "object")
|
|
540
|
-
return "";
|
|
541
|
-
const blockText = block.text;
|
|
542
|
-
return typeof blockText === "string" ? blockText : "";
|
|
543
|
-
})
|
|
544
|
-
.filter(Boolean)
|
|
545
|
-
.join("\n")
|
|
546
|
-
.trim();
|
|
547
|
-
if (fromBlocks)
|
|
548
|
-
return fromBlocks;
|
|
549
|
-
}
|
|
550
|
-
return "";
|
|
551
|
-
}
|
|
552
|
-
function extractUsage(input) {
|
|
553
|
-
if (!input || typeof input !== "object")
|
|
554
|
-
return {};
|
|
555
|
-
const obj = input;
|
|
556
|
-
const usage = (obj.usage && typeof obj.usage === "object"
|
|
557
|
-
? obj.usage
|
|
558
|
-
: undefined) ?? obj;
|
|
559
|
-
const toStringOrUndefined = (value) => {
|
|
560
|
-
if (typeof value === "string" && value.trim())
|
|
561
|
-
return value.trim();
|
|
562
|
-
if (typeof value === "number" && Number.isFinite(value))
|
|
563
|
-
return String(value);
|
|
564
|
-
return undefined;
|
|
565
|
-
};
|
|
566
|
-
return {
|
|
567
|
-
input: toStringOrUndefined(usage.tokens_input) ??
|
|
568
|
-
toStringOrUndefined(usage.input_tokens) ??
|
|
569
|
-
toStringOrUndefined(usage.inputTokens),
|
|
570
|
-
output: toStringOrUndefined(usage.tokens_output) ??
|
|
571
|
-
toStringOrUndefined(usage.output_tokens) ??
|
|
572
|
-
toStringOrUndefined(usage.outputTokens),
|
|
573
|
-
};
|
|
574
|
-
}
|
|
575
|
-
function parseMessageEndEvent(event) {
|
|
576
|
-
if (!event || typeof event !== "object")
|
|
577
|
-
return null;
|
|
578
|
-
const root = event;
|
|
579
|
-
if (root.type !== "message_end")
|
|
580
|
-
return null;
|
|
581
|
-
const message = root.message;
|
|
582
|
-
if (!message || typeof message !== "object")
|
|
583
|
-
return null;
|
|
584
|
-
const msg = message;
|
|
585
|
-
const role = typeof msg.role === "string" ? msg.role : "";
|
|
586
|
-
if (!role)
|
|
587
|
-
return null;
|
|
588
|
-
const content = extractText(msg.content ?? msg);
|
|
589
|
-
return { role, content, usage: msg.usage };
|
|
590
|
-
}
|
|
591
|
-
function extractJsonFromMcpResult(result) {
|
|
592
|
-
if (!result || typeof result !== "object")
|
|
593
|
-
return undefined;
|
|
594
|
-
const obj = result;
|
|
595
|
-
if (!Array.isArray(obj.content) || obj.content.length === 0)
|
|
596
|
-
return undefined;
|
|
597
|
-
const text = obj.content
|
|
598
|
-
.map((entry) => {
|
|
599
|
-
if (!entry || typeof entry !== "object")
|
|
600
|
-
return "";
|
|
601
|
-
const raw = entry.text;
|
|
602
|
-
return typeof raw === "string" ? raw : "";
|
|
603
|
-
})
|
|
604
|
-
.filter(Boolean)
|
|
605
|
-
.join("\n");
|
|
606
|
-
if (!text)
|
|
607
|
-
return undefined;
|
|
608
|
-
try {
|
|
609
|
-
return JSON.parse(text);
|
|
610
|
-
}
|
|
611
|
-
catch {
|
|
612
|
-
return undefined;
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
function toToolResultText(result) {
|
|
616
|
-
return {
|
|
617
|
-
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
618
|
-
details: result,
|
|
619
|
-
};
|
|
620
|
-
}
|
|
621
|
-
function appendText(result, text) {
|
|
622
|
-
const existing = Array.isArray(result.content) ? [...result.content] : [];
|
|
623
|
-
existing.push({ type: "text", text });
|
|
624
|
-
return { ...result, content: existing };
|
|
625
|
-
}
|
|
626
|
-
let hostPiTuiResolved = false;
|
|
627
|
-
let hostPiTui = null;
|
|
628
|
-
function loadHostPiTui() {
|
|
629
|
-
if (hostPiTuiResolved)
|
|
630
|
-
return hostPiTui;
|
|
631
|
-
hostPiTuiResolved = true;
|
|
632
|
-
const anchors = [];
|
|
633
|
-
const entry = typeof process !== "undefined" && Array.isArray(process.argv) ? process.argv[1] : undefined;
|
|
634
|
-
if (entry)
|
|
635
|
-
anchors.push(entry); // pi process entry — sibling pi-tui
|
|
636
|
-
anchors.push(import.meta.url); // adapter-local (usually fails, kept last)
|
|
637
|
-
for (const anchor of anchors) {
|
|
638
|
-
let req;
|
|
639
|
-
try {
|
|
640
|
-
req = createRequire(anchor);
|
|
641
|
-
}
|
|
642
|
-
catch {
|
|
643
|
-
continue;
|
|
644
|
-
}
|
|
645
|
-
for (const name of ["@earendil-works/pi-tui", "@mariozechner/pi-tui"]) {
|
|
646
|
-
try {
|
|
647
|
-
const mod = req(name);
|
|
648
|
-
if (mod && typeof mod.Text === "function") {
|
|
649
|
-
hostPiTui = mod;
|
|
650
|
-
return mod;
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
catch {
|
|
654
|
-
/* try next anchor/name */
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
hostPiTui = null;
|
|
659
|
-
return null;
|
|
660
|
-
}
|
|
661
|
-
/** Return terminal-visible width while ignoring ANSI SGR sequences. */
|
|
662
|
-
export function visibleWidthOf(line) {
|
|
663
|
-
const mod = loadHostPiTui();
|
|
664
|
-
if (mod?.visibleWidth) {
|
|
665
|
-
try {
|
|
666
|
-
return mod.visibleWidth(line);
|
|
667
|
-
}
|
|
668
|
-
catch {
|
|
669
|
-
/* fall through to the dependency-free counter */
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
// eslint-disable-next-line no-control-regex
|
|
673
|
-
return [...line.replace(/\x1b\[[0-9;]*m/g, "")].length;
|
|
674
|
-
}
|
|
675
|
-
/** Truncate one ANSI-styled line without counting styling bytes as columns. */
|
|
676
|
-
export function truncateLineToWidth(line, width) {
|
|
677
|
-
const cols = Number.isFinite(width) && width > 0 ? Math.floor(width) : 80;
|
|
678
|
-
if (visibleWidthOf(line) <= cols)
|
|
679
|
-
return line;
|
|
680
|
-
const mod = loadHostPiTui();
|
|
681
|
-
if (mod?.truncateToWidth) {
|
|
682
|
-
try {
|
|
683
|
-
return mod.truncateToWidth(line, cols);
|
|
684
|
-
}
|
|
685
|
-
catch {
|
|
686
|
-
/* fall through to the dependency-free truncator */
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
// Preserve SGR styling while counting only visible code points.
|
|
690
|
-
// eslint-disable-next-line no-control-regex
|
|
691
|
-
const tokens = line.match(/\x1b\[[0-9;]*m|./gu) ?? [];
|
|
692
|
-
let visible = 0;
|
|
693
|
-
let sawStyle = false;
|
|
694
|
-
let result = "";
|
|
695
|
-
for (const token of tokens) {
|
|
696
|
-
// eslint-disable-next-line no-control-regex
|
|
697
|
-
if (/^\x1b\[[0-9;]*m$/.test(token)) {
|
|
698
|
-
result += token;
|
|
699
|
-
sawStyle = true;
|
|
700
|
-
continue;
|
|
701
|
-
}
|
|
702
|
-
if (visible >= cols)
|
|
703
|
-
break;
|
|
704
|
-
result += token;
|
|
705
|
-
visible += 1;
|
|
706
|
-
}
|
|
707
|
-
if (sawStyle && !result.endsWith("\x1b[0m"))
|
|
708
|
-
result += "\x1b[0m";
|
|
709
|
-
return result;
|
|
710
|
-
}
|
|
711
|
-
/** Clamp a line so custom TUI/status rendering cannot exceed terminal width. */
|
|
712
|
-
export function clampLineToWidth(line, width) {
|
|
713
|
-
return truncateLineToWidth(line, width);
|
|
714
|
-
}
|
|
715
|
-
function createTextComponent(text) {
|
|
716
|
-
// Canonical path: return the host pi-tui's real Text component. It wraps via
|
|
717
|
-
// wrapTextWithAnsi and is always width-safe, preserving theme colors.
|
|
718
|
-
const mod = loadHostPiTui();
|
|
719
|
-
if (mod?.Text)
|
|
720
|
-
return new mod.Text(text, 0, 0);
|
|
721
|
-
// Safety net only if the host pi-tui genuinely cannot be resolved. The object
|
|
722
|
-
// MUST satisfy pi-tui's child contract — pi's Box.render calls
|
|
723
|
-
// `child.render(width)` and `child.invalidate()` — and every returned line
|
|
724
|
-
// MUST fit `width` (pi throws "Rendered line exceeds terminal width"
|
|
725
|
-
// otherwise). The `width` Box passes is already the content width, so wrap to
|
|
726
|
-
// the full value; strip ANSI so char length equals visible width.
|
|
727
|
-
return {
|
|
728
|
-
text,
|
|
729
|
-
setText(next) {
|
|
730
|
-
this.text = next;
|
|
731
|
-
},
|
|
732
|
-
invalidate() {
|
|
733
|
-
/* no cached render state to clear */
|
|
734
|
-
},
|
|
735
|
-
render(width) {
|
|
736
|
-
const raw = String(this.text ?? "");
|
|
737
|
-
if (raw === "")
|
|
738
|
-
return [];
|
|
739
|
-
const cols = typeof width === "number" && Number.isFinite(width) && width > 0
|
|
740
|
-
? Math.floor(width)
|
|
741
|
-
: 80;
|
|
742
|
-
// eslint-disable-next-line no-control-regex
|
|
743
|
-
const ansi = /\x1b\[[0-9;]*m/g;
|
|
744
|
-
const lines = [];
|
|
745
|
-
for (const para of raw.replace(/\t/g, " ").replace(ansi, "").split("\n")) {
|
|
746
|
-
if (para.length === 0) {
|
|
747
|
-
lines.push("");
|
|
748
|
-
}
|
|
749
|
-
else {
|
|
750
|
-
for (let i = 0; i < para.length; i += cols) {
|
|
751
|
-
lines.push(para.slice(i, i + cols));
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
return lines;
|
|
756
|
-
},
|
|
757
|
-
};
|
|
758
|
-
}
|
|
759
|
-
/**
|
|
760
|
-
* Reuse the previous render's Text component when possible (pi docs
|
|
761
|
-
* recommend mutating `context.lastComponent` instead of recreating, which
|
|
762
|
-
* avoids TUI churn during streaming re-renders).
|
|
763
|
-
*/
|
|
764
|
-
function textComponent(context, text) {
|
|
765
|
-
const last = context
|
|
766
|
-
?.lastComponent;
|
|
767
|
-
if (last && typeof last.setText === "function") {
|
|
768
|
-
last.setText(text);
|
|
769
|
-
return last;
|
|
770
|
-
}
|
|
771
|
-
return createTextComponent(text);
|
|
772
|
-
}
|
|
773
|
-
function truncateText(text, max = 80) {
|
|
774
|
-
return text.length > max ? `${text.slice(0, Math.max(0, max - 3))}...` : text;
|
|
775
|
-
}
|
|
776
|
-
function quotedText(text, max = 72) {
|
|
777
|
-
return `"${truncateText(text, max)}"`;
|
|
778
|
-
}
|
|
779
|
-
function stringValue(value) {
|
|
780
|
-
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
781
|
-
}
|
|
782
|
-
function toolParamValue(request) {
|
|
783
|
-
return normalizeParams(request.params.tool_params);
|
|
784
|
-
}
|
|
785
|
-
function normalizeRenderRequest(mode, args) {
|
|
786
|
-
try {
|
|
787
|
-
return normalizeCodeModeRequest(mode, normalizeParams(args));
|
|
788
|
-
}
|
|
789
|
-
catch {
|
|
790
|
-
return null;
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
function formatOperationSubject(request) {
|
|
794
|
-
if (request.domain === "external") {
|
|
795
|
-
const toolName = stringValue(request.params.tool_name) ?? "tool";
|
|
796
|
-
return `${request.action} ${toolName}`;
|
|
797
|
-
}
|
|
798
|
-
return `${request.domain}/${request.action}`;
|
|
799
|
-
}
|
|
800
|
-
function summarizeInventoryItem(value) {
|
|
801
|
-
if (typeof value === "string" && value.trim()) {
|
|
802
|
-
return truncateText(value.trim(), 96);
|
|
803
|
-
}
|
|
804
|
-
if (!value || typeof value !== "object")
|
|
805
|
-
return null;
|
|
806
|
-
const item = value;
|
|
807
|
-
const toolName = stringValue(item.tool_name);
|
|
808
|
-
const sessionId = stringValue(item.session_id);
|
|
809
|
-
const instanceId = stringValue(item.instance_id);
|
|
810
|
-
const serverId = stringValue(item.server_id) ?? stringValue(item.id) ?? instanceId;
|
|
811
|
-
if (toolName && serverId) {
|
|
812
|
-
return truncateText(`${toolName} @ ${serverId}`, 96);
|
|
813
|
-
}
|
|
814
|
-
if (instanceId) {
|
|
815
|
-
return truncateText(sessionId ? `${instanceId} (session ${sessionId})` : instanceId, 96);
|
|
816
|
-
}
|
|
817
|
-
const label = stringValue(item.title) ??
|
|
818
|
-
stringValue(item.name) ??
|
|
819
|
-
stringValue(item.key) ??
|
|
820
|
-
stringValue(item.id) ??
|
|
821
|
-
stringValue(item.url) ??
|
|
822
|
-
serverId;
|
|
823
|
-
if (!label)
|
|
824
|
-
return null;
|
|
825
|
-
const suffix = [];
|
|
826
|
-
if (typeof item.running === "boolean") {
|
|
827
|
-
suffix.push(item.running ? "running" : "stopped");
|
|
828
|
-
}
|
|
829
|
-
if (typeof item.enabled === "boolean") {
|
|
830
|
-
suffix.push(item.enabled ? "enabled" : "disabled");
|
|
831
|
-
}
|
|
832
|
-
if (typeof item.available === "boolean" && item.available === false) {
|
|
833
|
-
suffix.push("unavailable");
|
|
834
|
-
}
|
|
835
|
-
if (Array.isArray(item.tools)) {
|
|
836
|
-
suffix.push(`${item.tools.length} ${item.tools.length === 1 ? "tool" : "tools"}`);
|
|
837
|
-
}
|
|
838
|
-
else if (typeof item.tool_count === "number") {
|
|
839
|
-
suffix.push(`${item.tool_count} ${item.tool_count === 1 ? "tool" : "tools"}`);
|
|
840
|
-
}
|
|
841
|
-
const status = stringValue(item.status);
|
|
842
|
-
if (status && !suffix.includes(status))
|
|
843
|
-
suffix.push(status);
|
|
844
|
-
const decorated = suffix.length > 0 ? `${label} (${suffix.slice(0, 3).join(", ")})` : label;
|
|
845
|
-
return truncateText(decorated, 96);
|
|
846
|
-
}
|
|
847
|
-
function formatOperationDetails(request) {
|
|
848
|
-
const details = [];
|
|
849
|
-
if (request.domain === "external") {
|
|
850
|
-
const toolName = stringValue(request.params.tool_name);
|
|
851
|
-
const toolParams = toolParamValue(request);
|
|
852
|
-
const query = stringValue(toolParams.query);
|
|
853
|
-
const url = stringValue(toolParams.url);
|
|
854
|
-
const libraryId = stringValue(toolParams.context7CompatibleLibraryID);
|
|
855
|
-
if (toolName)
|
|
856
|
-
details.push(`tool: ${toolName}`);
|
|
857
|
-
if (query)
|
|
858
|
-
details.push(`query: ${quotedText(query)}`);
|
|
859
|
-
if (url)
|
|
860
|
-
details.push(`url: ${truncateText(url, 84)}`);
|
|
861
|
-
if (libraryId)
|
|
862
|
-
details.push(`library: ${truncateText(libraryId, 84)}`);
|
|
863
|
-
return details;
|
|
864
|
-
}
|
|
865
|
-
const query = stringValue(request.params.query);
|
|
866
|
-
const key = stringValue(request.params.key) ?? stringValue(request.params.name);
|
|
867
|
-
const serverId = stringValue(request.params.server_id);
|
|
868
|
-
const sessionId = stringValue(request.params.session_id);
|
|
869
|
-
const library = stringValue(request.params.library);
|
|
870
|
-
if (query)
|
|
871
|
-
details.push(`query: ${quotedText(query)}`);
|
|
872
|
-
if (key)
|
|
873
|
-
details.push(`key: ${truncateText(key, 84)}`);
|
|
874
|
-
if (serverId)
|
|
875
|
-
details.push(`server: ${serverId}`);
|
|
876
|
-
if (sessionId)
|
|
877
|
-
details.push(`session: ${truncateText(sessionId, 84)}`);
|
|
878
|
-
if (library)
|
|
879
|
-
details.push(`library: ${truncateText(library, 84)}`);
|
|
880
|
-
return details;
|
|
881
|
-
}
|
|
882
|
-
function summarizePayloadItems(payload) {
|
|
883
|
-
const namedArrays = [
|
|
884
|
-
["results", "results"],
|
|
885
|
-
["tools", "tools"],
|
|
886
|
-
["servers", "servers"],
|
|
887
|
-
["running_servers", "servers"],
|
|
888
|
-
["sessions", "sessions"],
|
|
889
|
-
["skills", "skills"],
|
|
890
|
-
["prompts", "prompts"],
|
|
891
|
-
["libraries", "libraries"],
|
|
892
|
-
["items", "items"],
|
|
893
|
-
["rooms", "rooms"],
|
|
894
|
-
["messages", "messages"],
|
|
895
|
-
];
|
|
896
|
-
if (Array.isArray(payload)) {
|
|
897
|
-
return {
|
|
898
|
-
count: payload.length,
|
|
899
|
-
label: payload.length === 1 ? "item" : "items",
|
|
900
|
-
items: payload
|
|
901
|
-
.map(summarizeInventoryItem)
|
|
902
|
-
.filter((item) => Boolean(item))
|
|
903
|
-
.slice(0, 4),
|
|
904
|
-
};
|
|
905
|
-
}
|
|
906
|
-
if (!payload || typeof payload !== "object")
|
|
907
|
-
return null;
|
|
908
|
-
const obj = payload;
|
|
909
|
-
for (const [key, label] of namedArrays) {
|
|
910
|
-
const items = obj[key];
|
|
911
|
-
if (!Array.isArray(items))
|
|
912
|
-
continue;
|
|
913
|
-
return {
|
|
914
|
-
count: items.length,
|
|
915
|
-
label: items.length === 1 ? label.replace(/s$/, "") : label,
|
|
916
|
-
items: items
|
|
917
|
-
.map(summarizeInventoryItem)
|
|
918
|
-
.filter((item) => Boolean(item))
|
|
919
|
-
.slice(0, 4),
|
|
920
|
-
};
|
|
921
|
-
}
|
|
922
|
-
return null;
|
|
923
|
-
}
|
|
924
|
-
function defaultOperationStatus(request) {
|
|
925
|
-
switch (request?.action) {
|
|
926
|
-
case "search":
|
|
927
|
-
return "searched";
|
|
928
|
-
case "list":
|
|
929
|
-
return "loaded";
|
|
930
|
-
case "get":
|
|
931
|
-
return "fetched";
|
|
932
|
-
case "use":
|
|
933
|
-
case "activate":
|
|
934
|
-
return "activated";
|
|
935
|
-
case "deactivate":
|
|
936
|
-
return "deactivated";
|
|
937
|
-
case "update":
|
|
938
|
-
return "updated";
|
|
939
|
-
case "expose":
|
|
940
|
-
return "exposed";
|
|
941
|
-
case "unexpose":
|
|
942
|
-
return "unexposed";
|
|
943
|
-
case "add":
|
|
944
|
-
return "added";
|
|
945
|
-
case "remove":
|
|
946
|
-
case "delete":
|
|
947
|
-
return "removed";
|
|
948
|
-
case "call":
|
|
949
|
-
case "execute":
|
|
950
|
-
return "called";
|
|
951
|
-
case "run":
|
|
952
|
-
return "ran";
|
|
953
|
-
case "create":
|
|
954
|
-
return "created";
|
|
955
|
-
case "sync":
|
|
956
|
-
return "synced";
|
|
957
|
-
case "start":
|
|
958
|
-
return "started";
|
|
959
|
-
case "stop":
|
|
960
|
-
return "stopped";
|
|
961
|
-
case "status":
|
|
962
|
-
return "status loaded";
|
|
963
|
-
default:
|
|
964
|
-
return "completed";
|
|
965
|
-
}
|
|
966
|
-
}
|
|
967
|
-
/**
|
|
968
|
-
* Compact digest of an object payload for the expanded tool view: top-level
|
|
969
|
-
* scalar fields, one per line, so the user never sees "raw payload hidden"
|
|
970
|
-
* for structured results (and never a wall of JSON either).
|
|
971
|
-
*/
|
|
972
|
-
function payloadDigest(payload, maxLines = 6) {
|
|
973
|
-
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
974
|
-
return [];
|
|
975
|
-
}
|
|
976
|
-
const lines = [];
|
|
977
|
-
for (const [key, value] of Object.entries(payload)) {
|
|
978
|
-
if (lines.length >= maxLines)
|
|
979
|
-
break;
|
|
980
|
-
if (value === null || value === undefined)
|
|
981
|
-
continue;
|
|
982
|
-
if (typeof value === "object") {
|
|
983
|
-
const size = Array.isArray(value)
|
|
984
|
-
? `${value.length} item${value.length === 1 ? "" : "s"}`
|
|
985
|
-
: `${Object.keys(value).length} field${Object.keys(value).length === 1 ? "" : "s"}`;
|
|
986
|
-
lines.push(`${key}: (${size})`);
|
|
987
|
-
continue;
|
|
988
|
-
}
|
|
989
|
-
lines.push(`${key}: ${truncateText(String(value), 80)}`);
|
|
990
|
-
}
|
|
991
|
-
return lines;
|
|
992
|
-
}
|
|
993
|
-
function summarizeRenderedResult(request, details, isError) {
|
|
994
|
-
if (isError) {
|
|
995
|
-
const errorText = extractText(details);
|
|
996
|
-
const preview = errorText
|
|
997
|
-
? [truncateText(errorText.split(/\r?\n/)[0] ?? errorText, 120)]
|
|
998
|
-
: [];
|
|
999
|
-
return { status: "failed", preview };
|
|
1000
|
-
}
|
|
1001
|
-
const payload = extractJsonFromMcpResult(details) ?? details;
|
|
1002
|
-
if (payload && typeof payload === "object") {
|
|
1003
|
-
const obj = payload;
|
|
1004
|
-
const explicitStatus = stringValue(obj.status);
|
|
1005
|
-
if (request?.domain === "hub" && request.action === "start") {
|
|
1006
|
-
const tools = Array.isArray(obj.tools)
|
|
1007
|
-
? obj.tools
|
|
1008
|
-
.map((item) => summarizeInventoryItem(item))
|
|
1009
|
-
.filter((item) => Boolean(item))
|
|
1010
|
-
.slice(0, 3)
|
|
1011
|
-
: [];
|
|
1012
|
-
const preview = [
|
|
1013
|
-
...tools,
|
|
1014
|
-
...[
|
|
1015
|
-
stringValue(obj.hint),
|
|
1016
|
-
stringValue(obj.message),
|
|
1017
|
-
stringValue(obj.summary),
|
|
1018
|
-
]
|
|
1019
|
-
.filter((item) => Boolean(item))
|
|
1020
|
-
.map((item) => truncateText(item, 120)),
|
|
1021
|
-
].slice(0, 3);
|
|
1022
|
-
return {
|
|
1023
|
-
status: explicitStatus ?? defaultOperationStatus(request),
|
|
1024
|
-
preview,
|
|
1025
|
-
};
|
|
1026
|
-
}
|
|
1027
|
-
const listSummary = summarizePayloadItems(payload);
|
|
1028
|
-
if (listSummary) {
|
|
1029
|
-
const preview = listSummary.items.length
|
|
1030
|
-
? listSummary.items
|
|
1031
|
-
: stringValue(obj.hint)
|
|
1032
|
-
? [truncateText(stringValue(obj.hint) ?? "", 120)]
|
|
1033
|
-
: [];
|
|
1034
|
-
return {
|
|
1035
|
-
status: `${listSummary.count} ${listSummary.label}`,
|
|
1036
|
-
preview,
|
|
1037
|
-
};
|
|
1038
|
-
}
|
|
1039
|
-
const message = stringValue(obj.message) ??
|
|
1040
|
-
stringValue(obj.summary) ??
|
|
1041
|
-
stringValue(obj.hint);
|
|
1042
|
-
if (message) {
|
|
1043
|
-
return {
|
|
1044
|
-
status: explicitStatus ?? defaultOperationStatus(request),
|
|
1045
|
-
preview: [truncateText(message, 120)],
|
|
1046
|
-
};
|
|
1047
|
-
}
|
|
1048
|
-
if (explicitStatus) {
|
|
1049
|
-
return { status: explicitStatus, preview: [] };
|
|
1050
|
-
}
|
|
1051
|
-
if (obj.ok === true) {
|
|
1052
|
-
return { status: defaultOperationStatus(request), preview: [] };
|
|
1053
|
-
}
|
|
1054
|
-
}
|
|
1055
|
-
return { status: defaultOperationStatus(request), preview: [] };
|
|
1056
|
-
}
|
|
1057
|
-
function renderSageToolCall(mode, args, theme) {
|
|
1058
|
-
const request = normalizeRenderRequest(mode, args);
|
|
1059
|
-
const toolName = mode === "search" ? "sage_search " : "sage_execute ";
|
|
1060
|
-
if (!request) {
|
|
1061
|
-
return createTextComponent(theme.fg("toolTitle", theme.bold(toolName)) +
|
|
1062
|
-
theme.fg("warning", "(invalid request)"));
|
|
1063
|
-
}
|
|
1064
|
-
const lines = [
|
|
1065
|
-
theme.fg("toolTitle", theme.bold(toolName)) +
|
|
1066
|
-
theme.fg("accent", formatOperationSubject(request)),
|
|
1067
|
-
];
|
|
1068
|
-
for (const detail of formatOperationDetails(request).slice(0, 3)) {
|
|
1069
|
-
lines.push(theme.fg("dim", ` ${detail}`));
|
|
1070
|
-
}
|
|
1071
|
-
return createTextComponent(lines.join("\n"));
|
|
1072
|
-
}
|
|
1073
|
-
function renderSageToolResult(mode, result, options, theme, context) {
|
|
1074
|
-
if (options.isPartial) {
|
|
1075
|
-
// Track elapsed time across streaming re-renders via the per-call
|
|
1076
|
-
// renderer state pi provides (context.state persists for this call).
|
|
1077
|
-
const state = context?.state;
|
|
1078
|
-
if (state && typeof state.sageStartedAt !== "number") {
|
|
1079
|
-
state.sageStartedAt = Date.now();
|
|
1080
|
-
}
|
|
1081
|
-
const startedAt = state && typeof state.sageStartedAt === "number"
|
|
1082
|
-
? state.sageStartedAt
|
|
1083
|
-
: undefined;
|
|
1084
|
-
const elapsed = startedAt
|
|
1085
|
-
? Math.round((Date.now() - startedAt) / 1000)
|
|
1086
|
-
: 0;
|
|
1087
|
-
const verb = mode === "search" ? "Searching" : "Executing";
|
|
1088
|
-
const suffix = elapsed >= 1 ? ` (${elapsed}s)` : "";
|
|
1089
|
-
return textComponent(context, theme.fg("warning", `${verb}...${suffix}`));
|
|
1090
|
-
}
|
|
1091
|
-
const request = normalizeRenderRequest(mode, context?.args);
|
|
1092
|
-
const isError = Boolean(context?.isError ?? result.isError);
|
|
1093
|
-
const summary = summarizeRenderedResult(request, result.details, isError);
|
|
1094
|
-
const statusColor = summary.status === "failed" ? "error" : "success";
|
|
1095
|
-
// Status line: verb + subject. The call block rendered immediately above
|
|
1096
|
-
// already shows the arg details — do not repeat them here. Instead show
|
|
1097
|
-
// the OUTCOME: the first preview line is visible even when collapsed.
|
|
1098
|
-
const lines = [
|
|
1099
|
-
theme.fg(statusColor, summary.status) +
|
|
1100
|
-
theme.fg("muted", " · ") +
|
|
1101
|
-
theme.fg("accent", request
|
|
1102
|
-
? formatOperationSubject(request)
|
|
1103
|
-
: mode === "search"
|
|
1104
|
-
? "search"
|
|
1105
|
-
: "execute") +
|
|
1106
|
-
(summary.preview.length > 0
|
|
1107
|
-
? theme.fg("muted", " — ") +
|
|
1108
|
-
theme.fg("muted", truncateText(summary.preview[0] ?? "", 100))
|
|
1109
|
-
: ""),
|
|
1110
|
-
];
|
|
1111
|
-
if (options.expanded) {
|
|
1112
|
-
const rest = summary.preview.slice(1);
|
|
1113
|
-
if (rest.length > 0) {
|
|
1114
|
-
for (const line of rest) {
|
|
1115
|
-
lines.push(theme.fg("muted", ` ${line}`));
|
|
1116
|
-
}
|
|
1117
|
-
}
|
|
1118
|
-
else if (summary.preview.length === 0 && !isError) {
|
|
1119
|
-
const digest = payloadDigest(extractJsonFromMcpResult(result.details) ?? result.details);
|
|
1120
|
-
if (digest.length > 0) {
|
|
1121
|
-
for (const line of digest) {
|
|
1122
|
-
lines.push(theme.fg("dim", ` ${line}`));
|
|
1123
|
-
}
|
|
1124
|
-
}
|
|
1125
|
-
else {
|
|
1126
|
-
lines.push(theme.fg("dim", " no result payload"));
|
|
1127
|
-
}
|
|
1128
|
-
}
|
|
1129
|
-
}
|
|
1130
|
-
return textComponent(context, lines.join("\n"));
|
|
1131
|
-
}
|
|
1132
|
-
function looksUnknownToolError(error) {
|
|
1133
|
-
const msg = error instanceof Error
|
|
1134
|
-
? error.message.toLowerCase()
|
|
1135
|
-
: String(error).toLowerCase();
|
|
1136
|
-
return (msg.includes("unknown tool") ||
|
|
1137
|
-
msg.includes("not found") ||
|
|
1138
|
-
msg.includes("no such tool") ||
|
|
1139
|
-
msg.includes("does not exist"));
|
|
1140
|
-
}
|
|
1141
|
-
const FAILED_NATIVE_SKILL_LOAD_RESULT = {
|
|
1142
|
-
schema: "sage.native-skill-load-signal.v1",
|
|
1143
|
-
status: "failed",
|
|
1144
|
-
recorded: false,
|
|
1145
|
-
};
|
|
1146
|
-
const NATIVE_SKILL_LOAD_EVIDENCE_TIERS = new Set([
|
|
1147
|
-
"full_serve",
|
|
1148
|
-
"native_observed",
|
|
1149
|
-
]);
|
|
1150
|
-
function parseJsonObject(stdout) {
|
|
1151
|
-
try {
|
|
1152
|
-
const parsed = JSON.parse(stdout.trim());
|
|
1153
|
-
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
1154
|
-
? parsed
|
|
1155
|
-
: null;
|
|
1156
|
-
}
|
|
1157
|
-
catch {
|
|
1158
|
-
return null;
|
|
1159
|
-
}
|
|
1160
|
-
}
|
|
1161
|
-
function parseNativeSkillLoadResult(stdout) {
|
|
1162
|
-
const parsed = parseJsonObject(stdout);
|
|
1163
|
-
if (!parsed ||
|
|
1164
|
-
parsed.schema !== "sage.native-skill-load-signal.v1") {
|
|
1165
|
-
return null;
|
|
1166
|
-
}
|
|
1167
|
-
if (parsed.status === "ignored" && parsed.recorded === false) {
|
|
1168
|
-
const result = {
|
|
1169
|
-
schema: "sage.native-skill-load-signal.v1",
|
|
1170
|
-
status: "ignored",
|
|
1171
|
-
recorded: false,
|
|
1172
|
-
};
|
|
1173
|
-
if (typeof parsed.reason === "string" && parsed.reason.trim()) {
|
|
1174
|
-
result.reason = parsed.reason;
|
|
1175
|
-
}
|
|
1176
|
-
return result;
|
|
1177
|
-
}
|
|
1178
|
-
if (parsed.status !== "recorded" ||
|
|
1179
|
-
parsed.recorded !== true ||
|
|
1180
|
-
typeof parsed.qualified_key !== "string" ||
|
|
1181
|
-
!parsed.qualified_key.trim() ||
|
|
1182
|
-
parsed.load_evidence_tier !== "native_observed" ||
|
|
1183
|
-
typeof parsed.loaded_at_ms !== "number" ||
|
|
1184
|
-
!Number.isSafeInteger(parsed.loaded_at_ms) ||
|
|
1185
|
-
parsed.loaded_at_ms < 0) {
|
|
1186
|
-
return null;
|
|
1187
|
-
}
|
|
1188
|
-
return {
|
|
1189
|
-
schema: "sage.native-skill-load-signal.v1",
|
|
1190
|
-
status: "recorded",
|
|
1191
|
-
recorded: true,
|
|
1192
|
-
qualified_key: parsed.qualified_key,
|
|
1193
|
-
load_evidence_tier: "native_observed",
|
|
1194
|
-
loaded_at_ms: parsed.loaded_at_ms,
|
|
1195
|
-
};
|
|
1196
|
-
}
|
|
1197
|
-
function normalizeNativeSkillName(value) {
|
|
1198
|
-
return value.trim().toLocaleLowerCase("en-US");
|
|
1199
|
-
}
|
|
1200
|
-
function dedupeNativeSkillNames(skillKeys) {
|
|
1201
|
-
const names = new Map();
|
|
1202
|
-
for (const raw of skillKeys) {
|
|
1203
|
-
const trimmed = raw.trim();
|
|
1204
|
-
if (!trimmed)
|
|
1205
|
-
continue;
|
|
1206
|
-
const normalized = normalizeNativeSkillName(trimmed);
|
|
1207
|
-
if (!names.has(normalized))
|
|
1208
|
-
names.set(normalized, trimmed);
|
|
1209
|
-
}
|
|
1210
|
-
return [...names.values()];
|
|
1211
|
-
}
|
|
1212
|
-
function parseNativeSkillUseResult(stdout, expectedCaptureId, expectedNames) {
|
|
1213
|
-
const parsed = parseJsonObject(stdout);
|
|
1214
|
-
if (!parsed ||
|
|
1215
|
-
parsed.schema !== "sage.native-skill-use.v1" ||
|
|
1216
|
-
parsed.status !== "recorded" ||
|
|
1217
|
-
parsed.capture_id !== expectedCaptureId ||
|
|
1218
|
-
!Array.isArray(parsed.uses) ||
|
|
1219
|
-
parsed.uses.length === 0) {
|
|
1220
|
-
return null;
|
|
1221
|
-
}
|
|
1222
|
-
const expected = new Set(expectedNames.map(normalizeNativeSkillName));
|
|
1223
|
-
if (expected.size === 0 || parsed.uses.length !== expected.size)
|
|
1224
|
-
return null;
|
|
1225
|
-
if (parsed.capture_linkage !== "explicit")
|
|
1226
|
-
return null;
|
|
1227
|
-
const returnedNames = new Set();
|
|
1228
|
-
const returnedQualifiedKeys = new Set();
|
|
1229
|
-
const uses = [];
|
|
1230
|
-
for (const entry of parsed.uses) {
|
|
1231
|
-
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
|
1232
|
-
return null;
|
|
1233
|
-
const record = entry;
|
|
1234
|
-
if (typeof record.requested_name !== "string" ||
|
|
1235
|
-
!record.requested_name.trim() ||
|
|
1236
|
-
typeof record.qualified_key !== "string" ||
|
|
1237
|
-
!record.qualified_key.trim() ||
|
|
1238
|
-
typeof record.load_evidence_tier !== "string" ||
|
|
1239
|
-
!NATIVE_SKILL_LOAD_EVIDENCE_TIERS.has(record.load_evidence_tier) ||
|
|
1240
|
-
typeof record.load_observed_at_ms !== "number" ||
|
|
1241
|
-
!Number.isSafeInteger(record.load_observed_at_ms) ||
|
|
1242
|
-
record.load_observed_at_ms < 0 ||
|
|
1243
|
-
typeof record.recorded !== "boolean") {
|
|
1244
|
-
return null;
|
|
1245
|
-
}
|
|
1246
|
-
const requestedName = record.requested_name.trim();
|
|
1247
|
-
const normalizedName = normalizeNativeSkillName(requestedName);
|
|
1248
|
-
const qualifiedKey = record.qualified_key.trim();
|
|
1249
|
-
const normalizedQualifiedKey = normalizeNativeSkillName(qualifiedKey);
|
|
1250
|
-
if (!expected.has(normalizedName) ||
|
|
1251
|
-
returnedNames.has(normalizedName) ||
|
|
1252
|
-
returnedQualifiedKeys.has(normalizedQualifiedKey)) {
|
|
1253
|
-
return null;
|
|
1254
|
-
}
|
|
1255
|
-
returnedNames.add(normalizedName);
|
|
1256
|
-
returnedQualifiedKeys.add(normalizedQualifiedKey);
|
|
1257
|
-
uses.push({
|
|
1258
|
-
requested_name: requestedName,
|
|
1259
|
-
qualified_key: qualifiedKey,
|
|
1260
|
-
load_evidence_tier: record.load_evidence_tier,
|
|
1261
|
-
load_observed_at_ms: record.load_observed_at_ms,
|
|
1262
|
-
recorded: record.recorded,
|
|
1263
|
-
});
|
|
1264
|
-
}
|
|
1265
|
-
if ([...expected].some((name) => !returnedNames.has(name)))
|
|
1266
|
-
return null;
|
|
1267
|
-
return {
|
|
1268
|
-
schema: "sage.native-skill-use.v1",
|
|
1269
|
-
status: "recorded",
|
|
1270
|
-
capture_id: expectedCaptureId,
|
|
1271
|
-
capture_linkage: "explicit",
|
|
1272
|
-
uses,
|
|
1273
|
-
};
|
|
1274
|
-
}
|
|
1275
|
-
function classifyNativeSkillUseFailure(failure) {
|
|
1276
|
-
if (failure?.timedOut) {
|
|
1277
|
-
return {
|
|
1278
|
-
code: "native_use_timeout",
|
|
1279
|
-
message: "Sage timed out while recording native guided use.",
|
|
1280
|
-
recovery: "Check `sage daemon status`, then retry the same declaration.",
|
|
1281
|
-
};
|
|
1282
|
-
}
|
|
1283
|
-
const parsed = failure ? parseJsonObject(failure.stdout) : null;
|
|
1284
|
-
const error = parsed?.error && typeof parsed.error === "object" && !Array.isArray(parsed.error)
|
|
1285
|
-
? parsed.error
|
|
1286
|
-
: null;
|
|
1287
|
-
if (parsed?.schema === "sage.native-skill-use.v1" &&
|
|
1288
|
-
parsed.status === "unsupported" &&
|
|
1289
|
-
error?.code === "native_skill_use_unsupported") {
|
|
1290
|
-
return {
|
|
1291
|
-
code: "native_skill_use_unsupported",
|
|
1292
|
-
message: "The running Sage daemon does not advertise native_skill_use_v1.",
|
|
1293
|
-
recovery: "Update and restart `saged`, then retry the same declaration.",
|
|
1294
|
-
};
|
|
1295
|
-
}
|
|
1296
|
-
const detail = `${failure?.stderr ?? ""}\n${failure?.stdout ?? ""}`;
|
|
1297
|
-
if (/unrecognized subcommand\s+['"]native-use['"]/i.test(detail)) {
|
|
1298
|
-
return {
|
|
1299
|
-
code: "native_use_cli_unsupported",
|
|
1300
|
-
message: "The configured Sage CLI does not expose feedback native-use.",
|
|
1301
|
-
recovery: "Update the Sage CLI selected by `SAGE_BIN`, then retry. Restart `saged` only if the updated CLI reports a daemon capability mismatch.",
|
|
1302
|
-
};
|
|
1303
|
-
}
|
|
1304
|
-
return {
|
|
1305
|
-
code: "native_use_write_failed",
|
|
1306
|
-
message: "Sage did not persist the native guided-use batch.",
|
|
1307
|
-
recovery: "Check `sage daemon status` and daemon logs before retrying.",
|
|
1308
|
-
};
|
|
1309
|
-
}
|
|
1310
|
-
function parseSecurityDecision(stdout) {
|
|
1311
|
-
const lines = stdout
|
|
1312
|
-
.split(/\r?\n/)
|
|
1313
|
-
.map((line) => line.trim())
|
|
1314
|
-
.filter(Boolean)
|
|
1315
|
-
.reverse();
|
|
1316
|
-
for (const line of lines) {
|
|
1317
|
-
try {
|
|
1318
|
-
const parsed = JSON.parse(line);
|
|
1319
|
-
const rawDecision = typeof parsed.decision === "string" ? parsed.decision : "";
|
|
1320
|
-
if (!rawDecision)
|
|
1321
|
-
continue;
|
|
1322
|
-
const reason = typeof parsed.reason === "string" ? parsed.reason : undefined;
|
|
1323
|
-
if (rawDecision === "block") {
|
|
1324
|
-
const isWarn = typeof reason === "string" && /security warning/i.test(reason);
|
|
1325
|
-
return { decision: isWarn ? "warn" : "block", reason };
|
|
1326
|
-
}
|
|
1327
|
-
if (rawDecision === "warn")
|
|
1328
|
-
return { decision: "warn", reason };
|
|
1329
|
-
if (rawDecision === "allow")
|
|
1330
|
-
return { decision: "allow", reason };
|
|
1331
|
-
}
|
|
1332
|
-
catch (_error) {
|
|
1333
|
-
void _error;
|
|
1334
|
-
}
|
|
1335
|
-
}
|
|
1336
|
-
return null;
|
|
1337
|
-
}
|
|
1338
|
-
export class SageMcpBridge {
|
|
1339
|
-
command;
|
|
1340
|
-
args;
|
|
1341
|
-
env;
|
|
1342
|
-
requestTimeoutMs;
|
|
1343
|
-
proc = null;
|
|
1344
|
-
pending = new Map();
|
|
1345
|
-
ready = false;
|
|
1346
|
-
stopped = false;
|
|
1347
|
-
startPromise = null;
|
|
1348
|
-
stderrBuffer = "";
|
|
1349
|
-
degraded = false;
|
|
1350
|
-
lastHealthError = null;
|
|
1351
|
-
lastHealthCheckedAt = null;
|
|
1352
|
-
cachedToolNames = [];
|
|
1353
|
-
constructor(command, args, env = {}, requestTimeoutMs = 20_000) {
|
|
1354
|
-
this.command = command;
|
|
1355
|
-
this.args = args;
|
|
1356
|
-
this.env = env;
|
|
1357
|
-
this.requestTimeoutMs = requestTimeoutMs;
|
|
1358
|
-
}
|
|
1359
|
-
isReady() {
|
|
1360
|
-
return this.ready && this.proc !== null && !this.stopped;
|
|
1361
|
-
}
|
|
1362
|
-
isDegraded() {
|
|
1363
|
-
return this.degraded;
|
|
1364
|
-
}
|
|
1365
|
-
getHealthSnapshot() {
|
|
1366
|
-
return {
|
|
1367
|
-
bridgeConnected: this.isReady(),
|
|
1368
|
-
degraded: this.degraded,
|
|
1369
|
-
lastHealthError: this.lastHealthError,
|
|
1370
|
-
lastHealthCheckedAt: this.lastHealthCheckedAt,
|
|
1371
|
-
availableToolCount: this.cachedToolNames.length,
|
|
1372
|
-
availableToolNames: [...this.cachedToolNames],
|
|
1373
|
-
};
|
|
1374
|
-
}
|
|
1375
|
-
async start() {
|
|
1376
|
-
if (this.isReady())
|
|
1377
|
-
return;
|
|
1378
|
-
if (this.startPromise)
|
|
1379
|
-
return this.startPromise;
|
|
1380
|
-
this.startPromise = (async () => {
|
|
1381
|
-
this.stopped = false;
|
|
1382
|
-
await this.spawn();
|
|
1383
|
-
await this.initialize();
|
|
1384
|
-
// Fail-open health probe: mark degraded if tools/list fails post-init,
|
|
1385
|
-
// but never throw from start(). Tool-use call sites surface the
|
|
1386
|
-
// structured degraded error when the bridge is unreachable.
|
|
1387
|
-
await this.probeHealth(Math.min(this.requestTimeoutMs, 2_000));
|
|
1388
|
-
})();
|
|
1389
|
-
try {
|
|
1390
|
-
await this.startPromise;
|
|
1391
|
-
}
|
|
1392
|
-
finally {
|
|
1393
|
-
this.startPromise = null;
|
|
1394
|
-
}
|
|
1395
|
-
}
|
|
1396
|
-
async probeHealth(timeoutMs = Math.min(this.requestTimeoutMs, 2_000)) {
|
|
1397
|
-
this.lastHealthCheckedAt = Date.now();
|
|
1398
|
-
try {
|
|
1399
|
-
const tools = await this.listTools(timeoutMs);
|
|
1400
|
-
this.cachedToolNames = tools.map((t) => t.name).filter(Boolean);
|
|
1401
|
-
this.degraded = false;
|
|
1402
|
-
this.lastHealthError = null;
|
|
1403
|
-
return true;
|
|
1404
|
-
}
|
|
1405
|
-
catch (error) {
|
|
1406
|
-
this.degraded = true;
|
|
1407
|
-
this.lastHealthError =
|
|
1408
|
-
error instanceof Error ? error.message : String(error);
|
|
1409
|
-
return false;
|
|
1410
|
-
}
|
|
1411
|
-
}
|
|
1412
|
-
async stop() {
|
|
1413
|
-
this.stopped = true;
|
|
1414
|
-
this.ready = false;
|
|
1415
|
-
this.rejectAllPending("MCP bridge stopped");
|
|
1416
|
-
if (this.proc) {
|
|
1417
|
-
this.proc.kill("SIGTERM");
|
|
1418
|
-
this.proc = null;
|
|
1419
|
-
}
|
|
1420
|
-
}
|
|
1421
|
-
async listTools(timeoutMs) {
|
|
1422
|
-
const result = (await this.request("tools/list", {}, timeoutMs));
|
|
1423
|
-
return result.tools ?? [];
|
|
1424
|
-
}
|
|
1425
|
-
async callTool(name, args) {
|
|
1426
|
-
const result = (await this.request("tools/call", {
|
|
1427
|
-
name,
|
|
1428
|
-
arguments: args,
|
|
1429
|
-
}));
|
|
1430
|
-
if (result.isError) {
|
|
1431
|
-
const errorText = result.content
|
|
1432
|
-
?.map((entry) => entry.text ?? "")
|
|
1433
|
-
.filter(Boolean)
|
|
1434
|
-
.join("\n") || "MCP tool error";
|
|
1435
|
-
throw new Error(errorText);
|
|
1436
|
-
}
|
|
1437
|
-
return result;
|
|
1438
|
-
}
|
|
1439
|
-
async callCodeModeSearch(args) {
|
|
1440
|
-
this.assertReadyForToolCall();
|
|
1441
|
-
try {
|
|
1442
|
-
return await this.callWithFallback(CODE_MODE_SEARCH_TOOLS, args);
|
|
1443
|
-
}
|
|
1444
|
-
catch (error) {
|
|
1445
|
-
this.markDegradedFromError(error);
|
|
1446
|
-
throw error;
|
|
1447
|
-
}
|
|
1448
|
-
}
|
|
1449
|
-
async callCodeModeExecute(args) {
|
|
1450
|
-
this.assertReadyForToolCall();
|
|
1451
|
-
try {
|
|
1452
|
-
return await this.callWithFallback(CODE_MODE_EXECUTE_TOOLS, args);
|
|
1453
|
-
}
|
|
1454
|
-
catch (error) {
|
|
1455
|
-
this.markDegradedFromError(error);
|
|
1456
|
-
throw error;
|
|
1457
|
-
}
|
|
1458
|
-
}
|
|
1459
|
-
assertReadyForToolCall() {
|
|
1460
|
-
if (!this.isReady() || this.degraded) {
|
|
1461
|
-
const detail = this.lastHealthError
|
|
1462
|
-
? ` Last health error: ${this.lastHealthError}.`
|
|
1463
|
-
: "";
|
|
1464
|
-
throw new Error(`Sage MCP bridge unavailable; restart Pi or verify 'sage mcp start' works.${detail}`);
|
|
1465
|
-
}
|
|
1466
|
-
}
|
|
1467
|
-
markDegradedFromError(error) {
|
|
1468
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1469
|
-
// Only flip degraded on transport-level failures, not on known MCP-level
|
|
1470
|
-
// errors (for example `tools/list` returning an unknown tool). Transport
|
|
1471
|
-
// failures surface as "not running", "stopped", or plain JSON-RPC errors
|
|
1472
|
-
// from dead processes.
|
|
1473
|
-
const looksTransportFailure = /bridge stopped|not running|failed to spawn|request timed out|EPIPE|ECONNRESET|closed/i.test(message);
|
|
1474
|
-
if (!looksTransportFailure)
|
|
1475
|
-
return;
|
|
1476
|
-
this.degraded = true;
|
|
1477
|
-
this.lastHealthError = message;
|
|
1478
|
-
this.lastHealthCheckedAt = Date.now();
|
|
1479
|
-
}
|
|
1480
|
-
async callWithFallback(candidates, args) {
|
|
1481
|
-
let lastUnknownError = null;
|
|
1482
|
-
for (const toolName of candidates) {
|
|
1483
|
-
try {
|
|
1484
|
-
return await this.callTool(toolName, args);
|
|
1485
|
-
}
|
|
1486
|
-
catch (error) {
|
|
1487
|
-
if (!looksUnknownToolError(error))
|
|
1488
|
-
throw error;
|
|
1489
|
-
lastUnknownError =
|
|
1490
|
-
error instanceof Error ? error : new Error(String(error));
|
|
1491
|
-
}
|
|
1492
|
-
}
|
|
1493
|
-
if (lastUnknownError)
|
|
1494
|
-
throw lastUnknownError;
|
|
1495
|
-
throw new Error("Sage code-mode tools are unavailable");
|
|
1496
|
-
}
|
|
1497
|
-
async initialize() {
|
|
1498
|
-
await this.request("initialize", {
|
|
1499
|
-
protocolVersion: "2024-11-05",
|
|
1500
|
-
capabilities: {},
|
|
1501
|
-
clientInfo: { name: "sage-pi-adapter", version: "0.3.1" },
|
|
1502
|
-
});
|
|
1503
|
-
this.notify("notifications/initialized", {});
|
|
1504
|
-
this.ready = true;
|
|
1505
|
-
}
|
|
1506
|
-
async spawn() {
|
|
1507
|
-
await new Promise((resolve, reject) => {
|
|
1508
|
-
const proc = spawn(this.command, this.args, {
|
|
1509
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
1510
|
-
env: { ...process.env, ...this.env },
|
|
1511
|
-
});
|
|
1512
|
-
let settled = false;
|
|
1513
|
-
const rejectOnce = (err) => {
|
|
1514
|
-
if (settled)
|
|
1515
|
-
return;
|
|
1516
|
-
settled = true;
|
|
1517
|
-
reject(err);
|
|
1518
|
-
};
|
|
1519
|
-
proc.on("error", (err) => {
|
|
1520
|
-
if (!this.stopped)
|
|
1521
|
-
rejectOnce(err);
|
|
1522
|
-
});
|
|
1523
|
-
proc.on("exit", (code) => {
|
|
1524
|
-
if (this.stopped)
|
|
1525
|
-
return;
|
|
1526
|
-
const message = this.withRecentStderr(`MCP process exited${this.ready ? "" : " before initialization"} (code ${code ?? -1})`);
|
|
1527
|
-
this.ready = false;
|
|
1528
|
-
this.rejectAllPending(message);
|
|
1529
|
-
rejectOnce(new Error(message));
|
|
1530
|
-
});
|
|
1531
|
-
proc.stdin.on("error", () => {
|
|
1532
|
-
// Swallow EPIPE — the exit handler above handles cleanup
|
|
1533
|
-
});
|
|
1534
|
-
proc.stdout.setEncoding("utf8");
|
|
1535
|
-
let buffer = "";
|
|
1536
|
-
proc.stdout.on("data", (chunk) => {
|
|
1537
|
-
buffer += chunk;
|
|
1538
|
-
const lines = buffer.split("\n");
|
|
1539
|
-
buffer = lines.pop() ?? "";
|
|
1540
|
-
for (const line of lines) {
|
|
1541
|
-
if (line.trim())
|
|
1542
|
-
this.handleLine(line);
|
|
1543
|
-
}
|
|
1544
|
-
});
|
|
1545
|
-
proc.stderr.setEncoding("utf8");
|
|
1546
|
-
proc.stderr.on("data", (chunk) => {
|
|
1547
|
-
this.appendStderr(chunk);
|
|
1548
|
-
});
|
|
1549
|
-
this.proc = proc;
|
|
1550
|
-
settled = true;
|
|
1551
|
-
resolve();
|
|
1552
|
-
});
|
|
1553
|
-
}
|
|
1554
|
-
request(method, params, timeoutMs = this.requestTimeoutMs) {
|
|
1555
|
-
return new Promise((resolve, reject) => {
|
|
1556
|
-
if (!this.proc?.stdin?.writable) {
|
|
1557
|
-
reject(new Error("MCP process not running"));
|
|
1558
|
-
return;
|
|
1559
|
-
}
|
|
1560
|
-
const id = randomUUID();
|
|
1561
|
-
const timeout = setTimeout(() => {
|
|
1562
|
-
this.pending.delete(id);
|
|
1563
|
-
reject(new Error(this.withRecentStderr(`MCP request timed out: ${method}`)));
|
|
1564
|
-
}, timeoutMs);
|
|
1565
|
-
this.pending.set(id, { resolve, reject, timeout });
|
|
1566
|
-
const req = { jsonrpc: "2.0", id, method, params };
|
|
1567
|
-
this.proc.stdin.write(JSON.stringify(req) + "\n", (err) => {
|
|
1568
|
-
if (!err)
|
|
1569
|
-
return;
|
|
1570
|
-
this.pending.delete(id);
|
|
1571
|
-
clearTimeout(timeout);
|
|
1572
|
-
reject(new Error(this.withRecentStderr(`Failed to write MCP request: ${err.message}`)));
|
|
1573
|
-
});
|
|
1574
|
-
});
|
|
1575
|
-
}
|
|
1576
|
-
notify(method, params) {
|
|
1577
|
-
if (!this.proc?.stdin?.writable)
|
|
1578
|
-
return;
|
|
1579
|
-
const msg = { jsonrpc: "2.0", method, params };
|
|
1580
|
-
this.proc.stdin.write(JSON.stringify(msg) + "\n");
|
|
1581
|
-
}
|
|
1582
|
-
handleLine(line) {
|
|
1583
|
-
let msg;
|
|
1584
|
-
try {
|
|
1585
|
-
msg = JSON.parse(line);
|
|
1586
|
-
}
|
|
1587
|
-
catch {
|
|
1588
|
-
return;
|
|
1589
|
-
}
|
|
1590
|
-
if (msg.id === undefined || msg.id === null)
|
|
1591
|
-
return;
|
|
1592
|
-
const id = String(msg.id);
|
|
1593
|
-
const pending = this.pending.get(id);
|
|
1594
|
-
if (!pending)
|
|
1595
|
-
return;
|
|
1596
|
-
this.pending.delete(id);
|
|
1597
|
-
clearTimeout(pending.timeout);
|
|
1598
|
-
if (msg.error) {
|
|
1599
|
-
pending.reject(new Error(`MCP error: ${JSON.stringify(msg.error)}`));
|
|
1600
|
-
return;
|
|
1601
|
-
}
|
|
1602
|
-
pending.resolve(msg.result);
|
|
1603
|
-
}
|
|
1604
|
-
rejectAllPending(reason) {
|
|
1605
|
-
for (const pending of this.pending.values()) {
|
|
1606
|
-
clearTimeout(pending.timeout);
|
|
1607
|
-
pending.reject(new Error(reason));
|
|
1608
|
-
}
|
|
1609
|
-
this.pending.clear();
|
|
1610
|
-
}
|
|
1611
|
-
appendStderr(chunk) {
|
|
1612
|
-
this.stderrBuffer += chunk;
|
|
1613
|
-
const maxChars = 4000;
|
|
1614
|
-
if (this.stderrBuffer.length > maxChars) {
|
|
1615
|
-
this.stderrBuffer = this.stderrBuffer.slice(-maxChars);
|
|
1616
|
-
}
|
|
1617
|
-
}
|
|
1618
|
-
withRecentStderr(message) {
|
|
1619
|
-
const stderr = this.stderrBuffer.trim();
|
|
1620
|
-
if (!stderr)
|
|
1621
|
-
return message;
|
|
1622
|
-
return `${message}\nRecent MCP stderr:\n${stderr}`;
|
|
1623
|
-
}
|
|
1624
|
-
}
|
|
1625
|
-
export class SageHookRuntime {
|
|
1626
|
-
config;
|
|
1627
|
-
commandRunner;
|
|
1628
|
-
workingDirectory;
|
|
1629
|
-
sessionContextCache = new Map();
|
|
1630
|
-
skillVisibilityCache = null;
|
|
1631
|
-
softFailureState = new Map();
|
|
1632
|
-
constructor(config) {
|
|
1633
|
-
this.config = config;
|
|
1634
|
-
this.commandRunner = config.commandRunner ?? defaultSageCommandRunner;
|
|
1635
|
-
}
|
|
1636
|
-
setWorkingDirectory(cwd) {
|
|
1637
|
-
this.workingDirectory =
|
|
1638
|
-
typeof cwd === "string" && cwd.trim() ? cwd.trim() : undefined;
|
|
1639
|
-
}
|
|
1640
|
-
getSoftTimeout(maxTimeoutMs) {
|
|
1641
|
-
return Math.max(250, Math.min(this.config.timeoutMs, maxTimeoutMs));
|
|
1642
|
-
}
|
|
1643
|
-
clearSoftFailure(commandText) {
|
|
1644
|
-
this.softFailureState.delete(commandText);
|
|
1645
|
-
}
|
|
1646
|
-
logSoftFailure(commandText, message) {
|
|
1647
|
-
const now = Date.now();
|
|
1648
|
-
const previous = this.softFailureState.get(commandText);
|
|
1649
|
-
if (previous &&
|
|
1650
|
-
now - previous.lastLoggedAt < SAGE_SOFT_COMMAND_LOG_COOLDOWN_MS) {
|
|
1651
|
-
previous.suppressedCount += 1;
|
|
1652
|
-
this.softFailureState.set(commandText, previous);
|
|
1653
|
-
return;
|
|
1654
|
-
}
|
|
1655
|
-
const suffix = previous && previous.suppressedCount > 0
|
|
1656
|
-
? `\n[sage-pi-adapter] Suppressed ${previous.suppressedCount} similar timeout/error log(s) for ${commandText}`
|
|
1657
|
-
: "";
|
|
1658
|
-
this.softFailureState.set(commandText, {
|
|
1659
|
-
lastLoggedAt: now,
|
|
1660
|
-
suppressedCount: 0,
|
|
1661
|
-
});
|
|
1662
|
-
console.warn(`${message}${suffix}`);
|
|
1663
|
-
}
|
|
1664
|
-
async getSessionContext(sessionId) {
|
|
1665
|
-
const cacheKey = sessionId?.trim() || "__default__";
|
|
1666
|
-
const result = await this.run(["skill", "context", "--format", "pi"], { SAGE_SESSION_ID: sessionId ?? "" }, undefined, {
|
|
1667
|
-
failurePolicy: "warn",
|
|
1668
|
-
timeoutMs: this.getSoftTimeout(SAGE_HOOK_TIMEOUTS.sessionContext),
|
|
1669
|
-
});
|
|
1670
|
-
const context = result?.stdout.trim() ?? "";
|
|
1671
|
-
if (context) {
|
|
1672
|
-
this.sessionContextCache.set(cacheKey, context);
|
|
1673
|
-
return context;
|
|
1674
|
-
}
|
|
1675
|
-
return this.sessionContextCache.get(cacheKey) ?? "";
|
|
1676
|
-
}
|
|
1677
|
-
/**
|
|
1678
|
-
* Gather skill visibility summary for user-facing display.
|
|
1679
|
-
*
|
|
1680
|
-
* Runs `sage library list` and `sage library active` in parallel to
|
|
1681
|
-
* produce a compact summary of active libraries with skill counts.
|
|
1682
|
-
* Returns cached or empty summary when the CLI is unavailable.
|
|
1683
|
-
*/
|
|
1684
|
-
async getSkillVisibilitySummary() {
|
|
1685
|
-
const softRunOptions = {
|
|
1686
|
-
failurePolicy: "warn",
|
|
1687
|
-
timeoutMs: this.getSoftTimeout(SAGE_HOOK_TIMEOUTS.skillVisibility),
|
|
1688
|
-
};
|
|
1689
|
-
const [libraryResult, activeResult] = await Promise.allSettled([
|
|
1690
|
-
this.run(["library", "list"], {}, undefined, softRunOptions),
|
|
1691
|
-
this.run(["library", "active"], {}, undefined, softRunOptions),
|
|
1692
|
-
]);
|
|
1693
|
-
const libraries = [];
|
|
1694
|
-
const libraryOut = libraryResult.status === "fulfilled"
|
|
1695
|
-
? (libraryResult.value?.stdout.trim() ?? "")
|
|
1696
|
-
: "";
|
|
1697
|
-
if (libraryOut) {
|
|
1698
|
-
for (const line of libraryOut.split("\n")) {
|
|
1699
|
-
const m = line.match(/^\s*(.+?)\s+\((\d+)\s+prompts?,\s*(\d+)\s+skills?\)/);
|
|
1700
|
-
if (m) {
|
|
1701
|
-
libraries.push({
|
|
1702
|
-
name: m[1].trim(),
|
|
1703
|
-
prompts: Number.parseInt(m[2], 10),
|
|
1704
|
-
skills: Number.parseInt(m[3], 10),
|
|
1705
|
-
});
|
|
1706
|
-
}
|
|
1707
|
-
}
|
|
1708
|
-
}
|
|
1709
|
-
const totalSkills = libraries.reduce((s, l) => s + l.skills, 0);
|
|
1710
|
-
const activeOut = activeResult.status === "fulfilled"
|
|
1711
|
-
? (activeResult.value?.stdout.trim() ?? "")
|
|
1712
|
-
: "";
|
|
1713
|
-
const activeNames = [];
|
|
1714
|
-
if (activeOut) {
|
|
1715
|
-
for (const line of activeOut.split("\n")) {
|
|
1716
|
-
if (line.includes("Skill-activated libraries"))
|
|
1717
|
-
break;
|
|
1718
|
-
const numMatch = line.match(/^\s*\d+\.\s+(\S+)/);
|
|
1719
|
-
if (numMatch)
|
|
1720
|
-
activeNames.push(numMatch[1]);
|
|
1721
|
-
}
|
|
1722
|
-
}
|
|
1723
|
-
let summary = "";
|
|
1724
|
-
if (activeNames.length > 0) {
|
|
1725
|
-
const maxShow = 3;
|
|
1726
|
-
const shown = activeNames.slice(0, maxShow).map((name) => {
|
|
1727
|
-
const lib = libraries.find((l) => l.name === name);
|
|
1728
|
-
if (lib && lib.skills > 0)
|
|
1729
|
-
return `${name} (${lib.skills}s)`;
|
|
1730
|
-
if (lib && lib.prompts > 0)
|
|
1731
|
-
return `${name} (${lib.prompts}p)`;
|
|
1732
|
-
return name;
|
|
1733
|
-
});
|
|
1734
|
-
const SEP = unicodeSafe("\u00B7");
|
|
1735
|
-
const suffix = activeNames.length > maxShow
|
|
1736
|
-
? ` ${SEP} +${activeNames.length - maxShow} more`
|
|
1737
|
-
: "";
|
|
1738
|
-
summary = shown.join(` ${SEP} `) + suffix;
|
|
1739
|
-
}
|
|
1740
|
-
else if (libraries.length > 0) {
|
|
1741
|
-
summary = `${libraries.length} libs, ${totalSkills} skills`;
|
|
1742
|
-
}
|
|
1743
|
-
summary = summary ? `[Sage] ${summary}` : "";
|
|
1744
|
-
const current = {
|
|
1745
|
-
summary,
|
|
1746
|
-
libraryCount: libraries.length,
|
|
1747
|
-
skillCount: totalSkills,
|
|
1748
|
-
};
|
|
1749
|
-
if (current.summary) {
|
|
1750
|
-
this.skillVisibilityCache = current;
|
|
1751
|
-
return current;
|
|
1752
|
-
}
|
|
1753
|
-
return this.skillVisibilityCache ?? current;
|
|
1754
|
-
}
|
|
1755
|
-
/**
|
|
1756
|
-
* @deprecated Low-level compatibility helper. The default Pi path uses
|
|
1757
|
-
* native skill discovery/loading and does not call this per prompt.
|
|
1758
|
-
*/
|
|
1759
|
-
async suggestSkill(prompt, sessionId, model) {
|
|
1760
|
-
const trimmed = prompt.trim();
|
|
1761
|
-
if (!trimmed)
|
|
1762
|
-
return "";
|
|
1763
|
-
const args = [
|
|
1764
|
-
"suggest",
|
|
1765
|
-
"hook",
|
|
1766
|
-
"skill",
|
|
1767
|
-
"--limit",
|
|
1768
|
-
String(this.config.suggestLimit ?? 3),
|
|
1769
|
-
];
|
|
1770
|
-
if (this.config.enableProvision !== false)
|
|
1771
|
-
args.push("--provision");
|
|
1772
|
-
const result = await this.run(args, {
|
|
1773
|
-
PROMPT: trimmed,
|
|
1774
|
-
SAGE_PROMPT: trimmed,
|
|
1775
|
-
SAGE_SOURCE: this.config.source ?? "pi",
|
|
1776
|
-
SAGE_SESSION_ID: sessionId ?? "",
|
|
1777
|
-
SAGE_MODEL: model ?? "",
|
|
1778
|
-
}, JSON.stringify({ prompt: trimmed }), {
|
|
1779
|
-
failurePolicy: "warn",
|
|
1780
|
-
timeoutMs: this.getSoftTimeout(SAGE_HOOK_TIMEOUTS.suggest),
|
|
1781
|
-
});
|
|
1782
|
-
return result?.stdout.trim() ?? "";
|
|
1783
|
-
}
|
|
1784
|
-
async capturePrompt(prompt, sessionId) {
|
|
1785
|
-
if (this.config.enableRlmFeedback === false)
|
|
1786
|
-
return undefined;
|
|
1787
|
-
const trimmed = prompt.trim();
|
|
1788
|
-
if (!trimmed)
|
|
1789
|
-
return undefined;
|
|
1790
|
-
const result = await this.run(["capture", "hook", "prompt"], {
|
|
1791
|
-
PROMPT: trimmed,
|
|
1792
|
-
SAGE_PROMPT: trimmed,
|
|
1793
|
-
SAGE_SOURCE: this.config.source ?? "pi",
|
|
1794
|
-
SAGE_SESSION_ID: sessionId ?? "",
|
|
1795
|
-
}, JSON.stringify({ prompt: trimmed }), {
|
|
1796
|
-
failurePolicy: "silent",
|
|
1797
|
-
timeoutMs: this.getSoftTimeout(SAGE_HOOK_TIMEOUTS.capture),
|
|
1798
|
-
});
|
|
1799
|
-
const captureId = result?.stdout
|
|
1800
|
-
.trim()
|
|
1801
|
-
.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i)?.[0];
|
|
1802
|
-
return captureId;
|
|
1803
|
-
}
|
|
1804
|
-
async captureResponse(response, usage, sessionId, captureId) {
|
|
1805
|
-
if (this.config.enableRlmFeedback === false)
|
|
1806
|
-
return false;
|
|
1807
|
-
const trimmed = response.trim();
|
|
1808
|
-
if (!trimmed)
|
|
1809
|
-
return false;
|
|
1810
|
-
const args = ["capture", "hook", "response"];
|
|
1811
|
-
const exactCaptureId = captureId?.trim();
|
|
1812
|
-
if (exactCaptureId)
|
|
1813
|
-
args.push("--correlation-id", exactCaptureId);
|
|
1814
|
-
const result = await this.run(args, {
|
|
1815
|
-
SAGE_RESPONSE: trimmed,
|
|
1816
|
-
LAST_RESPONSE: trimmed,
|
|
1817
|
-
SAGE_SOURCE: this.config.source ?? "pi",
|
|
1818
|
-
SAGE_SESSION_ID: sessionId ?? "",
|
|
1819
|
-
SAGE_DEFER_SUGGESTION_FEEDBACK: "1",
|
|
1820
|
-
TOKENS_INPUT: usage.input ?? "",
|
|
1821
|
-
TOKENS_OUTPUT: usage.output ?? "",
|
|
1822
|
-
}, JSON.stringify({ response: trimmed }), {
|
|
1823
|
-
failurePolicy: "silent",
|
|
1824
|
-
timeoutMs: this.getSoftTimeout(SAGE_HOOK_TIMEOUTS.capture),
|
|
1825
|
-
});
|
|
1826
|
-
return result !== null;
|
|
1827
|
-
}
|
|
1828
|
-
async recordSuggestionUse(skillKeys, sessionId, correlationId) {
|
|
1829
|
-
if (this.config.enableRlmFeedback === false)
|
|
1830
|
-
return false;
|
|
1831
|
-
const used = [
|
|
1832
|
-
...new Set(skillKeys.map((key) => key.trim()).filter(Boolean)),
|
|
1833
|
-
];
|
|
1834
|
-
if (used.length === 0)
|
|
1835
|
-
return false;
|
|
1836
|
-
const args = [
|
|
1837
|
-
"suggest",
|
|
1838
|
-
"feedback",
|
|
1839
|
-
"use",
|
|
1840
|
-
...used.flatMap((key) => ["--used", key]),
|
|
1841
|
-
];
|
|
1842
|
-
const correlation = correlationId?.trim();
|
|
1843
|
-
if (correlation)
|
|
1844
|
-
args.push("--correlation-id", correlation);
|
|
1845
|
-
const result = await this.run(args, {
|
|
1846
|
-
SAGE_SOURCE: this.config.source ?? "pi",
|
|
1847
|
-
SAGE_SESSION_ID: sessionId ?? "",
|
|
1848
|
-
}, undefined, {
|
|
1849
|
-
failurePolicy: "silent",
|
|
1850
|
-
timeoutMs: this.getSoftTimeout(SAGE_HOOK_TIMEOUTS.feedback),
|
|
1851
|
-
});
|
|
1852
|
-
return result !== null;
|
|
1853
|
-
}
|
|
1854
|
-
/**
|
|
1855
|
-
* Atomically record use of procedures that Pi loaded natively. This path
|
|
1856
|
-
* intentionally has no fallback to legacy suggestion feedback: the daemon
|
|
1857
|
-
* must authorize every returned qualified record from session-scoped load
|
|
1858
|
-
* evidence and link the complete batch to the exact capture.
|
|
1859
|
-
*/
|
|
1860
|
-
async recordNativeSkillUse(skillKeys, sessionId, captureId) {
|
|
1861
|
-
return (await this.recordNativeSkillUseAttempt(skillKeys, sessionId, captureId)).result;
|
|
1862
|
-
}
|
|
1863
|
-
async recordNativeSkillUseAttempt(skillKeys, sessionId, captureId) {
|
|
1864
|
-
if (this.config.enableRlmFeedback === false)
|
|
1865
|
-
return { result: null, failure: null };
|
|
1866
|
-
const used = dedupeNativeSkillNames(skillKeys);
|
|
1867
|
-
const session = sessionId?.trim();
|
|
1868
|
-
const capture = captureId?.trim();
|
|
1869
|
-
if (used.length === 0 || !session || !capture)
|
|
1870
|
-
return { result: null, failure: null };
|
|
1871
|
-
const source = this.config.source?.trim() || "pi";
|
|
1872
|
-
let commandFailure;
|
|
1873
|
-
const result = await this.run([
|
|
1874
|
-
"suggest",
|
|
1875
|
-
"feedback",
|
|
1876
|
-
"native-use",
|
|
1877
|
-
...used.flatMap((key) => ["--used", key]),
|
|
1878
|
-
"--capture-id",
|
|
1879
|
-
capture,
|
|
1880
|
-
"--source",
|
|
1881
|
-
source,
|
|
1882
|
-
"--session",
|
|
1883
|
-
session,
|
|
1884
|
-
"--format",
|
|
1885
|
-
"json",
|
|
1886
|
-
], {
|
|
1887
|
-
SAGE_SOURCE: source,
|
|
1888
|
-
SAGE_SESSION_ID: session,
|
|
1889
|
-
}, undefined, {
|
|
1890
|
-
failurePolicy: "silent",
|
|
1891
|
-
timeoutMs: this.getSoftTimeout(SAGE_HOOK_TIMEOUTS.feedback),
|
|
1892
|
-
onFailure: (failure) => {
|
|
1893
|
-
commandFailure = failure;
|
|
1894
|
-
},
|
|
1895
|
-
});
|
|
1896
|
-
if (!result) {
|
|
1897
|
-
return { result: null, failure: classifyNativeSkillUseFailure(commandFailure) };
|
|
1898
|
-
}
|
|
1899
|
-
const parsed = parseNativeSkillUseResult(result.stdout, capture, used);
|
|
1900
|
-
if (!parsed) {
|
|
1901
|
-
return {
|
|
1902
|
-
result: null,
|
|
1903
|
-
failure: {
|
|
1904
|
-
code: "native_use_write_failed",
|
|
1905
|
-
message: "Sage returned an invalid native-use response.",
|
|
1906
|
-
recovery: "Check `sage daemon status` and daemon logs before retrying.",
|
|
1907
|
-
},
|
|
1908
|
-
};
|
|
1909
|
-
}
|
|
1910
|
-
return { result: parsed, failure: null };
|
|
1911
|
-
}
|
|
1912
|
-
/**
|
|
1913
|
-
* Record a daemon load receipt for a full skill procedure this session
|
|
1914
|
-
* loaded through Pi's native full SKILL.md read or sage_execute get. The
|
|
1915
|
-
* structured result only reports `recorded: true` after validating the
|
|
1916
|
-
* explicit CLI's versioned JSON receipt. Ignored and failed writes remain
|
|
1917
|
-
* non-recorded so callers can retry honestly.
|
|
1918
|
-
*/
|
|
1919
|
-
async recordSkillLoadSignal(skillKey, sessionId, via = "slash") {
|
|
1920
|
-
if (this.config.enableRlmFeedback === false) {
|
|
1921
|
-
return { ...FAILED_NATIVE_SKILL_LOAD_RESULT };
|
|
1922
|
-
}
|
|
1923
|
-
const key = skillKey.trim();
|
|
1924
|
-
const session = sessionId?.trim();
|
|
1925
|
-
// A receipt is session-scoped evidence. Without the real Pi session id,
|
|
1926
|
-
// the CLI would fall back to ambient env (a foreign harness session) and
|
|
1927
|
-
// record evidence that verifies the WRONG scope — worse than nothing.
|
|
1928
|
-
if (!key || !session)
|
|
1929
|
-
return { ...FAILED_NATIVE_SKILL_LOAD_RESULT };
|
|
1930
|
-
const source = this.config.source?.trim() || "pi";
|
|
1931
|
-
const result = await this.run([
|
|
1932
|
-
"suggest",
|
|
1933
|
-
"load-signal",
|
|
1934
|
-
"--name",
|
|
1935
|
-
key,
|
|
1936
|
-
"--via",
|
|
1937
|
-
via,
|
|
1938
|
-
"--source",
|
|
1939
|
-
source,
|
|
1940
|
-
"--session",
|
|
1941
|
-
session,
|
|
1942
|
-
"--format",
|
|
1943
|
-
"json",
|
|
1944
|
-
], {
|
|
1945
|
-
SAGE_SOURCE: source,
|
|
1946
|
-
SAGE_SESSION_ID: session,
|
|
1947
|
-
}, undefined, {
|
|
1948
|
-
failurePolicy: "silent",
|
|
1949
|
-
timeoutMs: this.getSoftTimeout(SAGE_HOOK_TIMEOUTS.feedback),
|
|
1950
|
-
});
|
|
1951
|
-
if (!result)
|
|
1952
|
-
return { ...FAILED_NATIVE_SKILL_LOAD_RESULT };
|
|
1953
|
-
return (parseNativeSkillLoadResult(result.stdout) ?? {
|
|
1954
|
-
...FAILED_NATIVE_SKILL_LOAD_RESULT,
|
|
1955
|
-
});
|
|
1956
|
-
}
|
|
1957
|
-
async recordSuggestionSkip(sessionId, correlationId) {
|
|
1958
|
-
if (this.config.enableRlmFeedback === false)
|
|
1959
|
-
return;
|
|
1960
|
-
const args = ["suggest", "feedback", "skip"];
|
|
1961
|
-
const correlation = correlationId?.trim();
|
|
1962
|
-
if (correlation)
|
|
1963
|
-
args.push("--correlation-id", correlation);
|
|
1964
|
-
await this.run(args, {
|
|
1965
|
-
SAGE_SOURCE: this.config.source ?? "pi",
|
|
1966
|
-
SAGE_SESSION_ID: sessionId ?? "",
|
|
1967
|
-
}, undefined, {
|
|
1968
|
-
failurePolicy: "silent",
|
|
1969
|
-
timeoutMs: this.getSoftTimeout(SAGE_HOOK_TIMEOUTS.feedback),
|
|
1970
|
-
});
|
|
1971
|
-
}
|
|
1972
|
-
async recordSuggestionReject(sessionId, correlationId) {
|
|
1973
|
-
if (this.config.enableRlmFeedback === false)
|
|
1974
|
-
return;
|
|
1975
|
-
const args = ["suggest", "feedback", "reject"];
|
|
1976
|
-
const correlation = correlationId?.trim();
|
|
1977
|
-
if (correlation)
|
|
1978
|
-
args.push("--correlation-id", correlation);
|
|
1979
|
-
await this.run(args, {
|
|
1980
|
-
SAGE_SOURCE: this.config.source ?? "pi",
|
|
1981
|
-
SAGE_SESSION_ID: sessionId ?? "",
|
|
1982
|
-
}, undefined, {
|
|
1983
|
-
failurePolicy: "silent",
|
|
1984
|
-
timeoutMs: this.getSoftTimeout(SAGE_HOOK_TIMEOUTS.feedback),
|
|
1985
|
-
});
|
|
1986
|
-
}
|
|
1987
|
-
async recordExecutionOutcome(skillKey, status, queryPreview, sessionId, _model, correlationId, captureId) {
|
|
1988
|
-
if (this.config.enableRlmFeedback === false)
|
|
1989
|
-
return;
|
|
1990
|
-
const key = skillKey.trim();
|
|
1991
|
-
if (!key)
|
|
1992
|
-
return;
|
|
1993
|
-
const args = ["suggest", "feedback", "outcome", key, "--status", status];
|
|
1994
|
-
const preview = queryPreview?.trim();
|
|
1995
|
-
if (preview)
|
|
1996
|
-
args.push("--query-preview", preview.slice(0, 500));
|
|
1997
|
-
const correlation = correlationId?.trim();
|
|
1998
|
-
if (correlation)
|
|
1999
|
-
args.push("--correlation-id", correlation);
|
|
2000
|
-
const exactCaptureId = captureId?.trim();
|
|
2001
|
-
if (exactCaptureId)
|
|
2002
|
-
args.push("--capture-id", exactCaptureId);
|
|
2003
|
-
await this.run(args, {
|
|
2004
|
-
SAGE_SOURCE: this.config.source ?? "pi",
|
|
2005
|
-
SAGE_SESSION_ID: sessionId ?? "",
|
|
2006
|
-
}, undefined, {
|
|
2007
|
-
failurePolicy: "silent",
|
|
2008
|
-
timeoutMs: this.getSoftTimeout(SAGE_HOOK_TIMEOUTS.feedback),
|
|
2009
|
-
});
|
|
2010
|
-
}
|
|
2011
|
-
async scanPreTool(toolName, toolInput, sessionId) {
|
|
2012
|
-
if (this.config.enableSecurityHooks === false)
|
|
2013
|
-
return null;
|
|
2014
|
-
const payload = {
|
|
2015
|
-
hook_event_name: "PreToolUse",
|
|
2016
|
-
tool_name: toolName,
|
|
2017
|
-
tool_input: toolInput,
|
|
2018
|
-
};
|
|
2019
|
-
const result = await this.run(["security", "scan-hook"], {
|
|
2020
|
-
SAGE_SOURCE: this.config.source ?? "pi",
|
|
2021
|
-
SAGE_SESSION_ID: sessionId ?? "",
|
|
2022
|
-
}, JSON.stringify(payload), {
|
|
2023
|
-
failurePolicy: "warn",
|
|
2024
|
-
timeoutMs: this.getSoftTimeout(SAGE_HOOK_TIMEOUTS.securityScan),
|
|
2025
|
-
});
|
|
2026
|
-
return result ? parseSecurityDecision(result.stdout) : null;
|
|
2027
|
-
}
|
|
2028
|
-
async scanPostTool(toolName, toolInput, toolResult, sessionId) {
|
|
2029
|
-
if (this.config.enableSecurityHooks === false)
|
|
2030
|
-
return null;
|
|
2031
|
-
const payload = {
|
|
2032
|
-
hook_event_name: "PostToolUse",
|
|
2033
|
-
tool_name: toolName,
|
|
2034
|
-
tool_input: toolInput,
|
|
2035
|
-
tool_response: toolResult,
|
|
2036
|
-
};
|
|
2037
|
-
const result = await this.run(["security", "scan-hook"], {
|
|
2038
|
-
SAGE_SOURCE: this.config.source ?? "pi",
|
|
2039
|
-
SAGE_SESSION_ID: sessionId ?? "",
|
|
2040
|
-
}, JSON.stringify(payload), {
|
|
2041
|
-
failurePolicy: "warn",
|
|
2042
|
-
timeoutMs: this.getSoftTimeout(SAGE_HOOK_TIMEOUTS.securityScan),
|
|
2043
|
-
});
|
|
2044
|
-
return result ? parseSecurityDecision(result.stdout) : null;
|
|
2045
|
-
}
|
|
2046
|
-
/**
|
|
2047
|
-
* Resolve skill content by name(s) via the `sage skill get` CLI call.
|
|
2048
|
-
* Returns a map of skill name -> SKILL.md content (or empty string on miss).
|
|
2049
|
-
*
|
|
2050
|
-
* NOTE: current sage binaries have no `skill get` subcommand, so this is a
|
|
2051
|
-
* legacy fallback that typically resolves nothing — the extension resolves
|
|
2052
|
-
* skills through the MCP bridge (`skills.get`) first and only calls this
|
|
2053
|
-
* when the bridge is unavailable (or a future CLI adds the subcommand).
|
|
2054
|
-
*/
|
|
2055
|
-
async resolveSkills(names) {
|
|
2056
|
-
const results = new Map();
|
|
2057
|
-
if (!names.length)
|
|
2058
|
-
return results;
|
|
2059
|
-
const settled = await Promise.allSettled(names.map(async (name) => {
|
|
2060
|
-
const result = await this.run(["skill", "get", name, "--format", "raw"], { SAGE_SOURCE: this.config.source ?? "pi" }, undefined, {
|
|
2061
|
-
failurePolicy: "warn",
|
|
2062
|
-
timeoutMs: this.getSoftTimeout(SAGE_HOOK_TIMEOUTS.resolveSkill),
|
|
2063
|
-
});
|
|
2064
|
-
const content = result?.stdout.trim() ?? "";
|
|
2065
|
-
return { name, content };
|
|
2066
|
-
}));
|
|
2067
|
-
for (const outcome of settled) {
|
|
2068
|
-
if (outcome.status === "fulfilled" && outcome.value.content) {
|
|
2069
|
-
results.set(outcome.value.name, outcome.value.content);
|
|
2070
|
-
}
|
|
2071
|
-
}
|
|
2072
|
-
return results;
|
|
2073
|
-
}
|
|
2074
|
-
/**
|
|
2075
|
-
* Query per-skill execution quality from the Sage daemon.
|
|
2076
|
-
* Calls `sage suggest feedback breakdown <key> --format json` and returns
|
|
2077
|
-
* pass rate + sample size. Returns null when the daemon is unavailable,
|
|
2078
|
-
* no data exists, or sample size is too small (<3).
|
|
2079
|
-
*
|
|
2080
|
-
* Results are cached for the session lifetime.
|
|
2081
|
-
*/
|
|
2082
|
-
qualityCache = new Map();
|
|
2083
|
-
async querySkillQuality(skillKey) {
|
|
2084
|
-
const cached = this.qualityCache.get(skillKey);
|
|
2085
|
-
if (cached !== undefined)
|
|
2086
|
-
return cached;
|
|
2087
|
-
try {
|
|
2088
|
-
const result = await this.run(["suggest", "feedback", "breakdown", skillKey, "--format", "json"], {}, undefined, {
|
|
2089
|
-
failurePolicy: "silent",
|
|
2090
|
-
timeoutMs: this.getSoftTimeout(SAGE_HOOK_TIMEOUTS.quality),
|
|
2091
|
-
});
|
|
2092
|
-
if (!result?.stdout) {
|
|
2093
|
-
this.qualityCache.set(skillKey, null);
|
|
2094
|
-
return null;
|
|
2095
|
-
}
|
|
2096
|
-
const parsed = JSON.parse(result.stdout);
|
|
2097
|
-
const passRate = parsed.totals?.pass_rate ?? 0;
|
|
2098
|
-
const sampleSize = parsed.totals?.sample_size ?? 0;
|
|
2099
|
-
if (sampleSize < 3) {
|
|
2100
|
-
this.qualityCache.set(skillKey, null);
|
|
2101
|
-
return null;
|
|
2102
|
-
}
|
|
2103
|
-
const entry = { passRate, sampleSize };
|
|
2104
|
-
this.qualityCache.set(skillKey, entry);
|
|
2105
|
-
return entry;
|
|
2106
|
-
}
|
|
2107
|
-
catch {
|
|
2108
|
-
this.qualityCache.set(skillKey, null);
|
|
2109
|
-
return null;
|
|
2110
|
-
}
|
|
2111
|
-
}
|
|
2112
|
-
/** Get the execution quality score for a skill, or null if unknown. */
|
|
2113
|
-
getSkillQuality(skillKey) {
|
|
2114
|
-
// Synchronous lookup from cache; call querySkillQuality first to populate.
|
|
2115
|
-
return this.qualityCache.get(skillKey) ?? null;
|
|
2116
|
-
}
|
|
2117
|
-
/**
|
|
2118
|
-
* Pre-fetch quality scores for a batch of skill keys.
|
|
2119
|
-
* Useful before rendering widget annotations to avoid N sequential CLI calls.
|
|
2120
|
-
*/
|
|
2121
|
-
async prefetchSkillQualities(keys) {
|
|
2122
|
-
await Promise.allSettled(keys.map((k) => this.querySkillQuality(k)));
|
|
2123
|
-
}
|
|
2124
|
-
async run(args, env, stdin, options = {}) {
|
|
2125
|
-
const commandText = formatSageCommand(this.config.sageBin, args);
|
|
2126
|
-
const failurePolicy = options.failurePolicy ?? "throw";
|
|
2127
|
-
try {
|
|
2128
|
-
const commandEnv = {
|
|
2129
|
-
HOME: process.env.HOME ?? "",
|
|
2130
|
-
PATH: process.env.PATH ?? "",
|
|
2131
|
-
...sagePassthroughEnv(),
|
|
2132
|
-
...(this.config.env ?? {}),
|
|
2133
|
-
...env,
|
|
2134
|
-
};
|
|
2135
|
-
if (this.config.sageProfile) {
|
|
2136
|
-
commandEnv.SAGE_PROFILE = this.config.sageProfile;
|
|
2137
|
-
}
|
|
2138
|
-
if (this.workingDirectory && !commandEnv.SAGE_WORKSPACE) {
|
|
2139
|
-
commandEnv.SAGE_WORKSPACE = this.workingDirectory;
|
|
2140
|
-
}
|
|
2141
|
-
const result = await this.commandRunner({
|
|
2142
|
-
command: this.config.sageBin,
|
|
2143
|
-
args,
|
|
2144
|
-
env: commandEnv,
|
|
2145
|
-
stdin,
|
|
2146
|
-
cwd: this.workingDirectory,
|
|
2147
|
-
timeoutMs: options.timeoutMs ?? this.config.timeoutMs,
|
|
2148
|
-
});
|
|
2149
|
-
if (result.timedOut || result.exitCode !== 0) {
|
|
2150
|
-
options.onFailure?.(result);
|
|
2151
|
-
const detail = (result.stderr || result.stdout || "").trim();
|
|
2152
|
-
const message = result.timedOut
|
|
2153
|
-
? `[sage-pi-adapter] Sage command timed out: ${commandText}`
|
|
2154
|
-
: `[sage-pi-adapter] Sage command failed (${result.exitCode}): ${commandText}${detail ? `\n${detail}` : ""}`;
|
|
2155
|
-
if (failurePolicy === "throw") {
|
|
2156
|
-
throw new Error(message);
|
|
2157
|
-
}
|
|
2158
|
-
if (failurePolicy === "warn") {
|
|
2159
|
-
this.logSoftFailure(commandText, message);
|
|
2160
|
-
}
|
|
2161
|
-
return null;
|
|
2162
|
-
}
|
|
2163
|
-
this.clearSoftFailure(commandText);
|
|
2164
|
-
return result;
|
|
2165
|
-
}
|
|
2166
|
-
catch (error) {
|
|
2167
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
2168
|
-
options.onFailure?.({
|
|
2169
|
-
exitCode: -1,
|
|
2170
|
-
stdout: "",
|
|
2171
|
-
stderr: detail,
|
|
2172
|
-
timedOut: false,
|
|
2173
|
-
});
|
|
2174
|
-
const message = `[sage-pi-adapter] Sage command error: ${commandText}${detail ? `\n${detail}` : ""}`;
|
|
2175
|
-
if (failurePolicy === "throw")
|
|
2176
|
-
throw new Error(message);
|
|
2177
|
-
if (failurePolicy === "warn") {
|
|
2178
|
-
this.logSoftFailure(commandText, message);
|
|
2179
|
-
}
|
|
2180
|
-
return null;
|
|
2181
|
-
}
|
|
2182
|
-
}
|
|
2183
|
-
}
|
|
2184
|
-
const SageDomainType = Type.Union([
|
|
2185
|
-
Type.Literal("prompts"),
|
|
2186
|
-
Type.Literal("behaviors"),
|
|
2187
|
-
Type.Literal("skills"),
|
|
2188
|
-
Type.Literal("builder"),
|
|
2189
|
-
Type.Literal("governance"),
|
|
2190
|
-
Type.Literal("chat"),
|
|
2191
|
-
Type.Literal("social"),
|
|
2192
|
-
Type.Literal("rlm"),
|
|
2193
|
-
Type.Literal("library_sync"),
|
|
2194
|
-
Type.Literal("security"),
|
|
2195
|
-
Type.Literal("meta"),
|
|
2196
|
-
Type.Literal("hub"),
|
|
2197
|
-
Type.Literal("activation"),
|
|
2198
|
-
Type.Literal("wallet"),
|
|
2199
|
-
Type.Literal("library"),
|
|
2200
|
-
Type.Literal("help"),
|
|
2201
|
-
Type.Literal("external"),
|
|
2202
|
-
], { description: "Sage domain namespace" });
|
|
2203
|
-
export function createSageSearchTool(mcpBridge) {
|
|
2204
|
-
return {
|
|
2205
|
-
name: "sage_search",
|
|
2206
|
-
label: "Sage: Search",
|
|
2207
|
-
promptSnippet: "Route read-only Sage code-mode requests: discover domains/actions, inspect context, query hub/external tools, and search capabilities",
|
|
2208
|
-
promptGuidelines: [
|
|
2209
|
-
"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",
|
|
2210
|
-
"Use sage_search({ domain: 'help', action: 'list', params: {} }) before guessing unknown Sage domains or actions",
|
|
2211
|
-
"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",
|
|
2212
|
-
"If Sage says an action is a mutation or non-search inspect action, retry with sage_execute instead of falling back to bash",
|
|
2213
|
-
],
|
|
2214
|
-
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. " +
|
|
2215
|
-
"Start with domain='help', action='list' when you do not know the route. " +
|
|
2216
|
-
"Use domain='meta', action='get_project_context' for local Sage state and domain='meta', action='suggest' with params={goal:'...'} when you need route suggestions. " +
|
|
2217
|
-
"Use domain='hub' for MCP hub inventory/status and domain='external' for external tool discovery/search. " +
|
|
2218
|
-
"Some heavy inspect actions such as prompts.get and skills.get are classified by Sage as non-search and should use sage_execute.",
|
|
2219
|
-
parameters: Type.Object({
|
|
2220
|
-
domain: Type.Optional(SageDomainType),
|
|
2221
|
-
action: Type.Optional(Type.String()),
|
|
2222
|
-
params: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
2223
|
-
}),
|
|
2224
|
-
renderCall: (args, theme) => renderSageToolCall("search", args, theme),
|
|
2225
|
-
renderResult: (result, options, theme, context) => renderSageToolResult("search", result, options, theme, context),
|
|
2226
|
-
execute: async (_toolCallId, params) => {
|
|
2227
|
-
try {
|
|
2228
|
-
const normalized = normalizeCodeModeRequest("search", params);
|
|
2229
|
-
const domain = normalized.domain;
|
|
2230
|
-
const action = normalized.action;
|
|
2231
|
-
const payload = normalized.params;
|
|
2232
|
-
if (domain === "external" &&
|
|
2233
|
-
!["list_servers", "search"].includes(action)) {
|
|
2234
|
-
return {
|
|
2235
|
-
content: [
|
|
2236
|
-
{
|
|
2237
|
-
type: "text",
|
|
2238
|
-
text: "For external domain, sage_search only supports actions: list_servers, search",
|
|
2239
|
-
},
|
|
2240
|
-
],
|
|
2241
|
-
isError: true,
|
|
2242
|
-
};
|
|
2243
|
-
}
|
|
2244
|
-
const result = await callCodeModeWithWebResilience(mcpBridge, "search", {
|
|
2245
|
-
domain,
|
|
2246
|
-
action,
|
|
2247
|
-
params: payload,
|
|
2248
|
-
});
|
|
2249
|
-
return toToolResultText(result);
|
|
2250
|
-
}
|
|
2251
|
-
catch (error) {
|
|
2252
|
-
return {
|
|
2253
|
-
content: [
|
|
2254
|
-
{
|
|
2255
|
-
type: "text",
|
|
2256
|
-
text: `sage_search failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
2257
|
-
},
|
|
2258
|
-
],
|
|
2259
|
-
isError: true,
|
|
2260
|
-
};
|
|
2261
|
-
}
|
|
2262
|
-
},
|
|
2263
|
-
};
|
|
2264
|
-
}
|
|
2265
|
-
export function createSageExecuteTool(mcpBridge) {
|
|
2266
|
-
return {
|
|
2267
|
-
name: "sage_execute",
|
|
2268
|
-
label: "Sage: Execute",
|
|
2269
|
-
promptSnippet: "Execute Sage mutations and non-search inspect actions: activate skills, fetch full prompt/skill bodies, manage hub servers, and call external tools",
|
|
2270
|
-
promptGuidelines: [
|
|
2271
|
-
"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",
|
|
2272
|
-
"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",
|
|
2273
|
-
"To fetch a URL: sage_execute({ domain: 'external', action: 'call', params: { tool_name: 'fetch', tool_params: { url: 'https://...' } } })",
|
|
2274
|
-
],
|
|
2275
|
-
description: "Execute Sage mutations and non-search inspect actions across prompts, behaviors, skills, builder, chat, library, activation, hub, and external tool surfaces. " +
|
|
2276
|
-
"Use this for prompts.get, skills.get/use, behaviors.run, activation.activate, library.prompt_add/skill_add, hub.start/stop, and external.call/execute. " +
|
|
2277
|
-
"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.",
|
|
2278
|
-
parameters: Type.Object({
|
|
2279
|
-
domain: Type.Optional(SageDomainType),
|
|
2280
|
-
action: Type.Optional(Type.String()),
|
|
2281
|
-
params: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
2282
|
-
}),
|
|
2283
|
-
renderCall: (args, theme) => renderSageToolCall("execute", args, theme),
|
|
2284
|
-
renderResult: (result, options, theme, context) => renderSageToolResult("execute", result, options, theme, context),
|
|
2285
|
-
execute: async (_toolCallId, params) => {
|
|
2286
|
-
try {
|
|
2287
|
-
const normalized = normalizeCodeModeRequest("execute", params);
|
|
2288
|
-
const domain = normalized.domain;
|
|
2289
|
-
const action = normalized.action;
|
|
2290
|
-
const payload = normalized.params;
|
|
2291
|
-
if (domain === "external" && !["execute", "call"].includes(action)) {
|
|
2292
|
-
return {
|
|
2293
|
-
content: [
|
|
2294
|
-
{
|
|
2295
|
-
type: "text",
|
|
2296
|
-
text: "For external domain, sage_execute only supports actions: execute, call",
|
|
2297
|
-
},
|
|
2298
|
-
],
|
|
2299
|
-
isError: true,
|
|
2300
|
-
};
|
|
2301
|
-
}
|
|
2302
|
-
const result = await callCodeModeWithWebResilience(mcpBridge, "execute", {
|
|
2303
|
-
domain,
|
|
2304
|
-
action,
|
|
2305
|
-
params: payload,
|
|
2306
|
-
});
|
|
2307
|
-
return toToolResultText(result);
|
|
2308
|
-
}
|
|
2309
|
-
catch (error) {
|
|
2310
|
-
const errorMsg = augmentHubStartErrorMessage(params, error instanceof Error ? error.message : String(error));
|
|
2311
|
-
return {
|
|
2312
|
-
content: [
|
|
2313
|
-
{
|
|
2314
|
-
type: "text",
|
|
2315
|
-
text: `sage_execute failed: ${errorMsg}`,
|
|
2316
|
-
},
|
|
2317
|
-
],
|
|
2318
|
-
isError: true,
|
|
2319
|
-
};
|
|
2320
|
-
}
|
|
2321
|
-
},
|
|
2322
|
-
};
|
|
2323
|
-
}
|
|
2324
|
-
const SAGE_CLI_REFERENCE = `
|
|
2325
|
-
## Sage Code-Mode Reference
|
|
2326
|
-
|
|
2327
|
-
### Route Discovery
|
|
2328
|
-
- List domains/actions: sage_search({ domain: "help", action: "list", params: {} })
|
|
2329
|
-
- Suggest the right route: sage_search({ domain: "meta", action: "suggest", params: { goal: "..." } })
|
|
2330
|
-
- Inspect local Sage state: sage_search({ domain: "meta", action: "get_project_context", params: {} })
|
|
2331
|
-
|
|
2332
|
-
### Skills / Prompts / Behaviors
|
|
2333
|
-
- Search skills: sage_search({ domain: "skills", action: "search", params: { query: "..." } })
|
|
2334
|
-
- Inspect skill body: sage_execute({ domain: "skills", action: "get", params: { key: "..." } })
|
|
2335
|
-
- Activate skill: sage_execute({ domain: "skills", action: "use", params: { key: "..." } })
|
|
2336
|
-
- Search prompts: sage_search({ domain: "prompts", action: "search", params: { query: "..." } })
|
|
2337
|
-
- Inspect prompt body: sage_execute({ domain: "prompts", action: "get", params: { key: "..." } })
|
|
2338
|
-
- Inspect behavior: sage_search({ domain: "behaviors", action: "show", params: { key: "..." } })
|
|
2339
|
-
|
|
2340
|
-
### Hub / External Tools
|
|
2341
|
-
- List MCP servers: sage_search({ domain: "hub", action: "list_servers", params: {} })
|
|
2342
|
-
- Hub status: sage_search({ domain: "hub", action: "status", params: {} })
|
|
2343
|
-
- Start server: sage_execute({ domain: "hub", action: "start", params: { server_id: "github" } })
|
|
2344
|
-
- Running external servers/tools: sage_search({ domain: "external", action: "list_servers", params: {} })
|
|
2345
|
-
- Web search: sage_search({ domain: "external", action: "search", params: { tool_name: "web_search", tool_params: { query: "..." } } })
|
|
2346
|
-
- URL fetch: sage_execute({ domain: "external", action: "call", params: { tool_name: "fetch", tool_params: { url: "https://..." } } })
|
|
2347
|
-
`.trim();
|
|
2348
|
-
function buildHubRenderContext(action, params, context) {
|
|
2349
|
-
return {
|
|
2350
|
-
...(context && typeof context === "object" ? context : {}),
|
|
2351
|
-
args: {
|
|
2352
|
-
domain: "hub",
|
|
2353
|
-
action,
|
|
2354
|
-
params,
|
|
2355
|
-
},
|
|
2356
|
-
};
|
|
2357
|
-
}
|
|
2358
|
-
function augmentHubStartErrorMessage(params, errorMsg) {
|
|
2359
|
-
try {
|
|
2360
|
-
const normalized = normalizeCodeModeRequest("execute", params);
|
|
2361
|
-
if (normalized.domain === "hub" &&
|
|
2362
|
-
normalized.action === "start" &&
|
|
2363
|
-
/EOF|empty|parse|unexpected end/i.test(errorMsg)) {
|
|
2364
|
-
const serverId = String(normalized.params.server_id ?? "unknown");
|
|
2365
|
-
const knownKeyRequirements = {
|
|
2366
|
-
"brave-search": "BRAVE_API_KEY",
|
|
2367
|
-
github: "GITHUB_TOKEN",
|
|
2368
|
-
};
|
|
2369
|
-
const requiredKey = knownKeyRequirements[serverId];
|
|
2370
|
-
if (requiredKey) {
|
|
2371
|
-
return (errorMsg +
|
|
2372
|
-
`. Server '${serverId}' likely failed because ${requiredKey} is not configured. ` +
|
|
2373
|
-
`Set it with: sage mcp hub env set ${serverId} ${requiredKey}=<your-key>`);
|
|
2374
|
-
}
|
|
2375
|
-
return (errorMsg +
|
|
2376
|
-
`. Server '${serverId}' may require environment variables (API keys). ` +
|
|
2377
|
-
`Check with: sage mcp hub env list ${serverId}`);
|
|
2378
|
-
}
|
|
2379
|
-
}
|
|
2380
|
-
catch {
|
|
2381
|
-
// Keep original error text if normalization fails.
|
|
2382
|
-
}
|
|
2383
|
-
return errorMsg;
|
|
2384
|
-
}
|
|
2385
|
-
export function createHubListServersTool(mcpBridge) {
|
|
2386
|
-
return {
|
|
2387
|
-
name: "hub_list_servers",
|
|
2388
|
-
label: "Hub: List Servers",
|
|
2389
|
-
promptSnippet: "List Sage MCP servers directly when you need inventory/status without composing a generic sage_search call",
|
|
2390
|
-
promptGuidelines: [
|
|
2391
|
-
"Prefer hub_list_servers() for simple MCP server inventory in Pi; it is the direct convenience wrapper over domain='hub', action='list_servers'",
|
|
2392
|
-
"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",
|
|
2393
|
-
],
|
|
2394
|
-
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:{} }).",
|
|
2395
|
-
parameters: Type.Object({}),
|
|
2396
|
-
renderCall: (_args, theme) => renderSageToolCall("search", { domain: "hub", action: "list_servers", params: {} }, theme),
|
|
2397
|
-
renderResult: (result, options, theme, context) => renderSageToolResult("search", result, options, theme, buildHubRenderContext("list_servers", {}, context)),
|
|
2398
|
-
execute: async () => {
|
|
2399
|
-
try {
|
|
2400
|
-
const result = await callCodeModeWithWebResilience(mcpBridge, "search", {
|
|
2401
|
-
domain: "hub",
|
|
2402
|
-
action: "list_servers",
|
|
2403
|
-
params: {},
|
|
2404
|
-
});
|
|
2405
|
-
return toToolResultText(result);
|
|
2406
|
-
}
|
|
2407
|
-
catch (error) {
|
|
2408
|
-
return {
|
|
2409
|
-
content: [
|
|
2410
|
-
{
|
|
2411
|
-
type: "text",
|
|
2412
|
-
text: `hub_list_servers failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
2413
|
-
},
|
|
2414
|
-
],
|
|
2415
|
-
isError: true,
|
|
2416
|
-
};
|
|
2417
|
-
}
|
|
2418
|
-
},
|
|
2419
|
-
};
|
|
2420
|
-
}
|
|
2421
|
-
export function createHubStatusTool(mcpBridge) {
|
|
2422
|
-
return {
|
|
2423
|
-
name: "hub_status",
|
|
2424
|
-
label: "Hub: Status",
|
|
2425
|
-
promptSnippet: "Inspect Sage MCP hub runtime status directly when you want running server state without composing a generic sage_search call",
|
|
2426
|
-
promptGuidelines: [
|
|
2427
|
-
"Prefer hub_status() for simple MCP runtime status in Pi; it is the direct convenience wrapper over domain='hub', action='status'",
|
|
2428
|
-
"Use sage_status() when you need full adapter health plus Sage context, not just hub runtime state",
|
|
2429
|
-
],
|
|
2430
|
-
description: "Inspect Sage MCP hub runtime status. This is a Pi-native convenience wrapper for sage_search({ domain:'hub', action:'status', params:{} }).",
|
|
2431
|
-
parameters: Type.Object({}),
|
|
2432
|
-
renderCall: (_args, theme) => renderSageToolCall("search", { domain: "hub", action: "status", params: {} }, theme),
|
|
2433
|
-
renderResult: (result, options, theme, context) => renderSageToolResult("search", result, options, theme, buildHubRenderContext("status", {}, context)),
|
|
2434
|
-
execute: async () => {
|
|
2435
|
-
try {
|
|
2436
|
-
const result = await callCodeModeWithWebResilience(mcpBridge, "search", {
|
|
2437
|
-
domain: "hub",
|
|
2438
|
-
action: "status",
|
|
2439
|
-
params: {},
|
|
2440
|
-
});
|
|
2441
|
-
return toToolResultText(result);
|
|
2442
|
-
}
|
|
2443
|
-
catch (error) {
|
|
2444
|
-
return {
|
|
2445
|
-
content: [
|
|
2446
|
-
{
|
|
2447
|
-
type: "text",
|
|
2448
|
-
text: `hub_status failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
2449
|
-
},
|
|
2450
|
-
],
|
|
2451
|
-
isError: true,
|
|
2452
|
-
};
|
|
2453
|
-
}
|
|
2454
|
-
},
|
|
2455
|
-
};
|
|
2456
|
-
}
|
|
2457
|
-
export function createHubSessionListTool(mcpBridge) {
|
|
2458
|
-
return {
|
|
2459
|
-
name: "hub_session_list",
|
|
2460
|
-
label: "Hub: Session List",
|
|
2461
|
-
promptSnippet: "List active Sage MCP hub sessions directly when you need isolated instance visibility without composing a generic sage_search call",
|
|
2462
|
-
promptGuidelines: [
|
|
2463
|
-
"Prefer hub_session_list() for simple hub session inspection in Pi; it wraps domain='hub', action='session_list'",
|
|
2464
|
-
"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",
|
|
2465
|
-
],
|
|
2466
|
-
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? } }).",
|
|
2467
|
-
parameters: Type.Object({
|
|
2468
|
-
server_id: Type.Optional(Type.String({
|
|
2469
|
-
description: "Optional hub server identifier to filter sessions",
|
|
2470
|
-
})),
|
|
2471
|
-
}),
|
|
2472
|
-
renderCall: (args, theme) => renderSageToolCall("search", {
|
|
2473
|
-
domain: "hub",
|
|
2474
|
-
action: "session_list",
|
|
2475
|
-
params: normalizeParams(args),
|
|
2476
|
-
}, theme),
|
|
2477
|
-
renderResult: (result, options, theme, context) => renderSageToolResult("search", result, options, theme, buildHubRenderContext("session_list", normalizeParams(context?.args), context)),
|
|
2478
|
-
execute: async (_toolCallId, params) => {
|
|
2479
|
-
try {
|
|
2480
|
-
const payload = normalizeParams(params);
|
|
2481
|
-
const result = await callCodeModeWithWebResilience(mcpBridge, "search", {
|
|
2482
|
-
domain: "hub",
|
|
2483
|
-
action: "session_list",
|
|
2484
|
-
params: payload,
|
|
2485
|
-
});
|
|
2486
|
-
return toToolResultText(result);
|
|
2487
|
-
}
|
|
2488
|
-
catch (error) {
|
|
2489
|
-
return {
|
|
2490
|
-
content: [
|
|
2491
|
-
{
|
|
2492
|
-
type: "text",
|
|
2493
|
-
text: `hub_session_list failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
2494
|
-
},
|
|
2495
|
-
],
|
|
2496
|
-
isError: true,
|
|
2497
|
-
};
|
|
2498
|
-
}
|
|
2499
|
-
},
|
|
2500
|
-
};
|
|
2501
|
-
}
|
|
2502
|
-
export function createHubStopServerTool(mcpBridge) {
|
|
2503
|
-
return {
|
|
2504
|
-
name: "hub_stop_server",
|
|
2505
|
-
label: "Hub: Stop Server",
|
|
2506
|
-
promptSnippet: "Stop a Sage MCP server directly when you know the server_id and want to end its runtime cleanly",
|
|
2507
|
-
promptGuidelines: [
|
|
2508
|
-
"Prefer hub_stop_server({ server_id: '...' }) for simple MCP server shutdown in Pi; it wraps domain='hub', action='stop'",
|
|
2509
|
-
"Use hub_status() or hub_list_servers() after stopping a server if you need to confirm the resulting hub state",
|
|
2510
|
-
],
|
|
2511
|
-
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:'...' } }).",
|
|
2512
|
-
parameters: Type.Object({
|
|
2513
|
-
server_id: Type.String({
|
|
2514
|
-
description: "Hub server identifier to stop, such as github or brave-search",
|
|
2515
|
-
}),
|
|
2516
|
-
session_id: Type.Optional(Type.String({
|
|
2517
|
-
description: "Optional hub session identifier for isolated instances",
|
|
2518
|
-
})),
|
|
2519
|
-
}),
|
|
2520
|
-
renderCall: (args, theme) => renderSageToolCall("execute", { domain: "hub", action: "stop", params: normalizeParams(args) }, theme),
|
|
2521
|
-
renderResult: (result, options, theme, context) => renderSageToolResult("execute", result, options, theme, buildHubRenderContext("stop", normalizeParams(context?.args), context)),
|
|
2522
|
-
execute: async (_toolCallId, params) => {
|
|
2523
|
-
try {
|
|
2524
|
-
const payload = normalizeParams(params);
|
|
2525
|
-
const result = await callCodeModeWithWebResilience(mcpBridge, "execute", {
|
|
2526
|
-
domain: "hub",
|
|
2527
|
-
action: "stop",
|
|
2528
|
-
params: payload,
|
|
2529
|
-
});
|
|
2530
|
-
return toToolResultText(result);
|
|
2531
|
-
}
|
|
2532
|
-
catch (error) {
|
|
2533
|
-
return {
|
|
2534
|
-
content: [
|
|
2535
|
-
{
|
|
2536
|
-
type: "text",
|
|
2537
|
-
text: `hub_stop_server failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
2538
|
-
},
|
|
2539
|
-
],
|
|
2540
|
-
isError: true,
|
|
2541
|
-
};
|
|
2542
|
-
}
|
|
2543
|
-
},
|
|
2544
|
-
};
|
|
2545
|
-
}
|
|
2546
|
-
export function createHubStartServerTool(mcpBridge) {
|
|
2547
|
-
return {
|
|
2548
|
-
name: "hub_start_server",
|
|
2549
|
-
label: "Hub: Start Server",
|
|
2550
|
-
promptSnippet: "Start a Sage MCP server directly when you already know the server_id and want Pi to expose its tools",
|
|
2551
|
-
promptGuidelines: [
|
|
2552
|
-
"Prefer hub_start_server({ server_id: '...' }) for simple MCP server startup in Pi; it wraps domain='hub', action='start'",
|
|
2553
|
-
"After starting an external MCP server, call its tools through sage_execute/sage_search with domain='external' and params { tool_name, tool_params }",
|
|
2554
|
-
],
|
|
2555
|
-
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:'...' } }).",
|
|
2556
|
-
parameters: Type.Object({
|
|
2557
|
-
server_id: Type.String({
|
|
2558
|
-
description: "Hub server identifier to start, such as github or brave-search",
|
|
2559
|
-
}),
|
|
2560
|
-
session_id: Type.Optional(Type.String({
|
|
2561
|
-
description: "Optional hub session identifier for isolated instances",
|
|
2562
|
-
})),
|
|
2563
|
-
}),
|
|
2564
|
-
renderCall: (args, theme) => renderSageToolCall("execute", { domain: "hub", action: "start", params: normalizeParams(args) }, theme),
|
|
2565
|
-
renderResult: (result, options, theme, context) => renderSageToolResult("execute", result, options, theme, buildHubRenderContext("start", normalizeParams(context?.args), context)),
|
|
2566
|
-
execute: async (_toolCallId, params) => {
|
|
2567
|
-
try {
|
|
2568
|
-
const payload = normalizeParams(params);
|
|
2569
|
-
const result = await callCodeModeWithWebResilience(mcpBridge, "execute", {
|
|
2570
|
-
domain: "hub",
|
|
2571
|
-
action: "start",
|
|
2572
|
-
params: payload,
|
|
2573
|
-
});
|
|
2574
|
-
return toToolResultText(result);
|
|
2575
|
-
}
|
|
2576
|
-
catch (error) {
|
|
2577
|
-
const errorMsg = augmentHubStartErrorMessage({ domain: "hub", action: "start", params }, error instanceof Error ? error.message : String(error));
|
|
2578
|
-
return {
|
|
2579
|
-
content: [
|
|
2580
|
-
{ type: "text", text: `hub_start_server failed: ${errorMsg}` },
|
|
2581
|
-
],
|
|
2582
|
-
isError: true,
|
|
2583
|
-
};
|
|
2584
|
-
}
|
|
2585
|
-
},
|
|
2586
|
-
};
|
|
2587
|
-
}
|
|
2588
|
-
export function createSageStatusTool(mcpBridge) {
|
|
2589
|
-
return {
|
|
2590
|
-
name: "sage_status",
|
|
2591
|
-
label: "Sage: Status",
|
|
2592
|
-
promptSnippet: "Check Sage connectivity, list available MCP servers and external tools",
|
|
2593
|
-
description: "Check Sage adapter health, list available MCP servers and tools, and verify connectivity. " +
|
|
2594
|
-
"Call this to see what external tools (web search, GitHub, fetch, browser automation, etc.) are available.",
|
|
2595
|
-
parameters: Type.Object({}),
|
|
2596
|
-
execute: async () => {
|
|
2597
|
-
const health = mcpBridge.getHealthSnapshot();
|
|
2598
|
-
const bridgeConnected = health.bridgeConnected && !health.degraded;
|
|
2599
|
-
if (bridgeConnected) {
|
|
2600
|
-
try {
|
|
2601
|
-
const context = await mcpBridge.callCodeModeSearch({
|
|
2602
|
-
domain: "meta",
|
|
2603
|
-
action: "get_project_context",
|
|
2604
|
-
params: {},
|
|
2605
|
-
});
|
|
2606
|
-
return {
|
|
2607
|
-
content: [
|
|
2608
|
-
{
|
|
2609
|
-
type: "text",
|
|
2610
|
-
text: JSON.stringify({
|
|
2611
|
-
bridgeConnected,
|
|
2612
|
-
degraded: false,
|
|
2613
|
-
sageAdapterVersion: "0.3.1",
|
|
2614
|
-
availableToolCount: health.availableToolCount,
|
|
2615
|
-
availableToolNames: health.availableToolNames,
|
|
2616
|
-
context: extractJsonFromMcpResult(context) ?? context,
|
|
2617
|
-
}, null, 2),
|
|
2618
|
-
},
|
|
2619
|
-
{
|
|
2620
|
-
type: "text",
|
|
2621
|
-
text: SAGE_CLI_REFERENCE,
|
|
2622
|
-
},
|
|
2623
|
-
],
|
|
2624
|
-
};
|
|
2625
|
-
}
|
|
2626
|
-
catch (error) {
|
|
2627
|
-
// Tool call failed even though the bridge claims ready; surface as
|
|
2628
|
-
// degraded rather than falling back silently.
|
|
2629
|
-
return {
|
|
2630
|
-
content: [
|
|
2631
|
-
{
|
|
2632
|
-
type: "text",
|
|
2633
|
-
text: JSON.stringify({
|
|
2634
|
-
bridgeConnected,
|
|
2635
|
-
degraded: true,
|
|
2636
|
-
sageAdapterVersion: "0.3.1",
|
|
2637
|
-
lastHealthError: error instanceof Error ? error.message : String(error),
|
|
2638
|
-
}, null, 2),
|
|
2639
|
-
},
|
|
2640
|
-
{
|
|
2641
|
-
type: "text",
|
|
2642
|
-
text: SAGE_CLI_REFERENCE,
|
|
2643
|
-
},
|
|
2644
|
-
],
|
|
2645
|
-
details: { bridgeConnected, degraded: true },
|
|
2646
|
-
};
|
|
2647
|
-
}
|
|
2648
|
-
}
|
|
2649
|
-
return {
|
|
2650
|
-
content: [
|
|
2651
|
-
{
|
|
2652
|
-
type: "text",
|
|
2653
|
-
text: JSON.stringify({
|
|
2654
|
-
bridgeConnected,
|
|
2655
|
-
degraded: health.degraded,
|
|
2656
|
-
sageAdapterVersion: "0.3.1",
|
|
2657
|
-
lastHealthError: health.lastHealthError,
|
|
2658
|
-
lastHealthCheckedAt: health.lastHealthCheckedAt,
|
|
2659
|
-
availableToolCount: health.availableToolCount,
|
|
2660
|
-
availableToolNames: health.availableToolNames,
|
|
2661
|
-
}, null, 2),
|
|
2662
|
-
},
|
|
2663
|
-
{
|
|
2664
|
-
type: "text",
|
|
2665
|
-
text: SAGE_CLI_REFERENCE,
|
|
2666
|
-
},
|
|
2667
|
-
],
|
|
2668
|
-
details: {
|
|
2669
|
-
bridgeConnected,
|
|
2670
|
-
degraded: health.degraded,
|
|
2671
|
-
lastHealthError: health.lastHealthError,
|
|
2672
|
-
},
|
|
2673
|
-
};
|
|
2674
|
-
},
|
|
2675
|
-
};
|
|
2676
|
-
}
|
|
2677
|
-
export function wrapToolsWithSageSecurity(tools, options) {
|
|
2678
|
-
return tools.map((tool) => {
|
|
2679
|
-
const originalExecute = tool.execute;
|
|
2680
|
-
return {
|
|
2681
|
-
...tool,
|
|
2682
|
-
execute: async (toolCallId, params, signal, onUpdate, ctx) => {
|
|
2683
|
-
const sessionId = options.getSessionId?.();
|
|
2684
|
-
const pre = await options.hookRuntime.scanPreTool(tool.name, params, sessionId);
|
|
2685
|
-
if (pre?.decision === "block") {
|
|
2686
|
-
return {
|
|
2687
|
-
content: [
|
|
2688
|
-
{
|
|
2689
|
-
type: "text",
|
|
2690
|
-
text: `Sage security blocked ${tool.name}: ${pre.reason ?? "high-risk payload detected"}`,
|
|
2691
|
-
},
|
|
2692
|
-
],
|
|
2693
|
-
details: {
|
|
2694
|
-
blockedBySageSecurity: true,
|
|
2695
|
-
reason: pre.reason,
|
|
2696
|
-
tool: tool.name,
|
|
2697
|
-
},
|
|
2698
|
-
isError: true,
|
|
2699
|
-
};
|
|
2700
|
-
}
|
|
2701
|
-
const result = await originalExecute(toolCallId, params, signal, onUpdate, ctx);
|
|
2702
|
-
const post = await options.hookRuntime.scanPostTool(tool.name, params, result, sessionId);
|
|
2703
|
-
if (post?.decision === "warn" || post?.decision === "block") {
|
|
2704
|
-
return appendText(result, `Sage security ${post.decision}: ${post.reason ?? "treat this output as untrusted content."}`);
|
|
2705
|
-
}
|
|
2706
|
-
return result;
|
|
2707
|
-
},
|
|
2708
|
-
};
|
|
2709
|
-
});
|
|
2710
|
-
}
|
|
2711
|
-
async function injectHookMessage(session, customType, content, display = false) {
|
|
2712
|
-
const trimmed = content.trim();
|
|
2713
|
-
if (!trimmed)
|
|
2714
|
-
return;
|
|
2715
|
-
if (typeof session.sendHookMessage === "function") {
|
|
2716
|
-
await session.sendHookMessage({
|
|
2717
|
-
customType,
|
|
2718
|
-
content: trimmed,
|
|
2719
|
-
display,
|
|
2720
|
-
}, false);
|
|
2721
|
-
return;
|
|
2722
|
-
}
|
|
2723
|
-
console.log(`\n[${customType}]\n${trimmed}\n`);
|
|
2724
|
-
}
|
|
2725
|
-
export function createSageExtensions(options) {
|
|
2726
|
-
const { mcpBridge } = options;
|
|
2727
|
-
const hookRuntime = options.hookRuntime ??
|
|
2728
|
-
new SageHookRuntime({
|
|
2729
|
-
sageBin: options.config.sageBin ?? "sage",
|
|
2730
|
-
timeoutMs: options.config.timeoutMs ?? 20_000,
|
|
2731
|
-
...options.config,
|
|
2732
|
-
});
|
|
2733
|
-
mcpBridge.start().catch((error) => {
|
|
2734
|
-
console.error("[sage-pi-adapter] failed to start MCP bridge:", error);
|
|
2735
|
-
});
|
|
2736
|
-
const sageTools = [
|
|
2737
|
-
createHubListServersTool(mcpBridge),
|
|
2738
|
-
createHubStatusTool(mcpBridge),
|
|
2739
|
-
createHubSessionListTool(mcpBridge),
|
|
2740
|
-
createHubStartServerTool(mcpBridge),
|
|
2741
|
-
createHubStopServerTool(mcpBridge),
|
|
2742
|
-
createSageSearchTool(mcpBridge),
|
|
2743
|
-
createSageExecuteTool(mcpBridge),
|
|
2744
|
-
createSageStatusTool(mcpBridge),
|
|
2745
|
-
];
|
|
2746
|
-
const customTools = wrapToolsWithSageSecurity(sageTools, {
|
|
2747
|
-
hookRuntime,
|
|
2748
|
-
getSessionId: options.getSessionId,
|
|
2749
|
-
});
|
|
2750
|
-
return {
|
|
2751
|
-
customTools,
|
|
2752
|
-
// Preserved for one compatibility release. Pi's native skill surface owns
|
|
2753
|
-
// discovery/loading now, so adapter-side prompt injection must stay inert.
|
|
2754
|
-
sageInjectSuggestion: async (_userPrompt, _session, _sessionId) => { },
|
|
2755
|
-
sageClearSuggestions: () => { },
|
|
2756
|
-
sageGetSuggestions: () => "",
|
|
2757
|
-
};
|
|
2758
|
-
}
|
|
2759
|
-
export async function createSageSessionConfig(config = {}) {
|
|
2760
|
-
const normalized = {
|
|
2761
|
-
sageBin: config.sageBin ?? "sage",
|
|
2762
|
-
timeoutMs: config.timeoutMs ?? 20_000,
|
|
2763
|
-
suggestLimit: config.suggestLimit ?? 3,
|
|
2764
|
-
suggestDebounceMs: config.suggestDebounceMs ?? 800,
|
|
2765
|
-
enableProvision: config.enableProvision ?? true,
|
|
2766
|
-
enableRlmFeedback: config.enableRlmFeedback ?? true,
|
|
2767
|
-
enableSecurityHooks: config.enableSecurityHooks ?? true,
|
|
2768
|
-
source: config.source ?? "pi",
|
|
2769
|
-
...config,
|
|
2770
|
-
};
|
|
2771
|
-
const sageEnv = {
|
|
2772
|
-
HOME: process.env.HOME ?? "",
|
|
2773
|
-
PATH: process.env.PATH ?? "",
|
|
2774
|
-
SAGE_FILTER_EXECUTION: "1", // enable execution multiplier in routing
|
|
2775
|
-
...(normalized.env ?? {}),
|
|
2776
|
-
};
|
|
2777
|
-
if (normalized.sageProfile) {
|
|
2778
|
-
sageEnv.SAGE_PROFILE = normalized.sageProfile;
|
|
2779
|
-
}
|
|
2780
|
-
const hookRuntime = new SageHookRuntime(normalized);
|
|
2781
|
-
let activeSessionId;
|
|
2782
|
-
const mcpBridge = new SageMcpBridge(normalized.sageBin, ["mcp", "start"], sageEnv, normalized.timeoutMs);
|
|
2783
|
-
const extension = createSageExtensions({
|
|
2784
|
-
config: normalized,
|
|
2785
|
-
mcpBridge,
|
|
2786
|
-
hookRuntime,
|
|
2787
|
-
getSessionId: () => activeSessionId,
|
|
2788
|
-
debounceMs: normalized.suggestDebounceMs,
|
|
2789
|
-
});
|
|
2790
|
-
const wrapTools = (tools) => wrapToolsWithSageSecurity(tools, {
|
|
2791
|
-
hookRuntime,
|
|
2792
|
-
getSessionId: () => activeSessionId,
|
|
2793
|
-
});
|
|
2794
|
-
return {
|
|
2795
|
-
customTools: extension.customTools,
|
|
2796
|
-
mcpBridge,
|
|
2797
|
-
wrapToolsWithSecurity: wrapTools,
|
|
2798
|
-
setupHooks: (session) => {
|
|
2799
|
-
activeSessionId = session.sessionId;
|
|
2800
|
-
// Inject Sage session context (skill context from CLI).
|
|
2801
|
-
hookRuntime
|
|
2802
|
-
.getSessionContext(activeSessionId)
|
|
2803
|
-
.then(async (context) => {
|
|
2804
|
-
if (!context)
|
|
2805
|
-
return;
|
|
2806
|
-
await injectHookMessage(session, "sage-session-context", context, false);
|
|
2807
|
-
})
|
|
2808
|
-
.catch((error) => {
|
|
2809
|
-
console.error("[sage-pi-adapter] failed to inject session context:", error);
|
|
2810
|
-
});
|
|
2811
|
-
// Show loaded skills/libraries summary to the user.
|
|
2812
|
-
hookRuntime
|
|
2813
|
-
.getSkillVisibilitySummary()
|
|
2814
|
-
.then(async (vis) => {
|
|
2815
|
-
if (!vis.summary)
|
|
2816
|
-
return;
|
|
2817
|
-
await injectHookMessage(session, "sage-loaded", vis.summary, true);
|
|
2818
|
-
})
|
|
2819
|
-
.catch((error) => {
|
|
2820
|
-
console.error("[sage-pi-adapter] failed to get skill summary:", error);
|
|
2821
|
-
});
|
|
2822
|
-
const unsubscribe = session.subscribe((event) => {
|
|
2823
|
-
const parsed = parseMessageEndEvent(event);
|
|
2824
|
-
if (!parsed)
|
|
2825
|
-
return;
|
|
2826
|
-
if (parsed.role === "user") {
|
|
2827
|
-
const content = parsed.content.trim();
|
|
2828
|
-
if (!content)
|
|
2829
|
-
return;
|
|
2830
|
-
void hookRuntime
|
|
2831
|
-
.capturePrompt(content, activeSessionId)
|
|
2832
|
-
.catch((error) => {
|
|
2833
|
-
console.error("[sage-pi-adapter] prompt capture failed:", error);
|
|
2834
|
-
});
|
|
2835
|
-
return;
|
|
2836
|
-
}
|
|
2837
|
-
if (parsed.role === "assistant") {
|
|
2838
|
-
const response = parsed.content.trim();
|
|
2839
|
-
if (!response)
|
|
2840
|
-
return;
|
|
2841
|
-
const usage = extractUsage(parsed.usage);
|
|
2842
|
-
void hookRuntime
|
|
2843
|
-
.captureResponse(response, usage, activeSessionId)
|
|
2844
|
-
.catch((error) => {
|
|
2845
|
-
console.error("[sage-pi-adapter] response capture failed:", error);
|
|
2846
|
-
});
|
|
2847
|
-
}
|
|
2848
|
-
});
|
|
2849
|
-
return () => {
|
|
2850
|
-
unsubscribe();
|
|
2851
|
-
};
|
|
2852
|
-
},
|
|
2853
|
-
};
|
|
2854
|
-
}
|
|
2855
|
-
export default {
|
|
2856
|
-
createSageExtensions,
|
|
2857
|
-
createSageSessionConfig,
|
|
2858
|
-
wrapToolsWithSageSecurity,
|
|
2859
|
-
SageMcpBridge,
|
|
2860
|
-
SageHookRuntime,
|
|
2861
|
-
};
|
|
2862
|
-
//# sourceMappingURL=sage-pi-adapter.js.map
|