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