@ynhcj/xiaoyi-channel 0.0.96-beta → 0.0.98-beta

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/dist/index.js CHANGED
@@ -6,6 +6,8 @@ import { tryInjectSteer } from "./src/steer-injector.js";
6
6
  import { callCsplApi } from "./src/cspl/call-api.js";
7
7
  import { extractResultText, processText, parseSecurityResult, validateAndTruncateText } from "./src/cspl/utils.js";
8
8
  import { ALLOWED_TOOLS, MIN_TEXT_LENGTH, MAX_TOTAL_LENGTH, MAX_TEXT_LENGTH, STEER_ABORT_MESSAGE, } from "./src/cspl/constants.js";
9
+ import { createBeforePromptBuildHandler } from "./src/skill-retriever/hooks.js";
10
+ import { normalizeToolRetrieverConfig } from "./src/skill-retriever/config.js";
9
11
  /**
10
12
  * Xiaoyi Channel Plugin Entry Point.
11
13
  * Exports the plugin for OpenClaw to load.
@@ -59,6 +61,17 @@ const plugin = {
59
61
  api.logger.error(`[SENTINEL HOOK] after_tool_call error: ${err}`);
60
62
  }
61
63
  });
64
+ // SKILL RETRIEVER HOOK: before_prompt_build hook
65
+ const pluginConfig = api.pluginConfig || {};
66
+ const skillRetrieverConfig = normalizeToolRetrieverConfig({
67
+ enabled: pluginConfig.skillRetrieverEnabled ?? true,
68
+ maxTools: pluginConfig.skillRetrieverMaxTools ?? 2,
69
+ includeUninstalledOnly: true,
70
+ envFilePath: "~/.openclaw/.xiaoyienv",
71
+ timeoutMs: pluginConfig.skillRetrieverTimeoutMs ?? 1000,
72
+ });
73
+ const beforePromptBuildHandler = createBeforePromptBuildHandler(skillRetrieverConfig);
74
+ api.on("before_prompt_build", beforePromptBuildHandler);
62
75
  },
63
76
  };
64
77
  export default plugin;
@@ -19,6 +19,7 @@ import { getCollectionToolSchemaTool } from "./tools/get-collection-tool-schema.
19
19
  import { queryAppMessageTool } from "./tools/query-app-message-tool.js";
20
20
  import { queryMemoryDataTool } from "./tools/query-memory-data-tool.js";
21
21
  import { queryTodoTaskTool } from "./tools/query-todo-task-tool.js";
22
+ import { loginTokenTool } from "./tools/login-token-tool.js";
22
23
  import { filterToolsByDevice } from "./tools/device-tool-map.js";
23
24
  import { getCurrentSessionContext } from "./tools/session-manager.js";
24
25
  import { logger } from "./utils/logger.js";
@@ -61,7 +62,7 @@ export const xyPlugin = {
61
62
  },
62
63
  outbound: xyOutbound,
63
64
  agentTools: () => {
64
- const allTools = [locationTool, callDeviceTool, getNoteToolSchemaTool, getCalendarToolSchemaTool, getContactToolSchemaTool, getPhotoToolSchemaTool, xiaoyiGuiTool, getDeviceFileToolSchemaTool, getAlarmToolSchemaTool, getCollectionToolSchemaTool, sendFileToUserTool, viewPushResultTool, imageReadingTool, timestampToUtc8Tool, getEmailToolSchemaTool, queryAppMessageTool, queryMemoryDataTool, queryTodoTaskTool];
65
+ const allTools = [locationTool, callDeviceTool, getNoteToolSchemaTool, getCalendarToolSchemaTool, getContactToolSchemaTool, getPhotoToolSchemaTool, xiaoyiGuiTool, getDeviceFileToolSchemaTool, getAlarmToolSchemaTool, getCollectionToolSchemaTool, sendFileToUserTool, viewPushResultTool, imageReadingTool, timestampToUtc8Tool, getEmailToolSchemaTool, queryAppMessageTool, queryMemoryDataTool, queryTodoTaskTool, loginTokenTool];
65
66
  const ctx = getCurrentSessionContext();
66
67
  const filtered = filterToolsByDevice(allTools, ctx?.deviceType);
67
68
  logger.log(`[DEVICE-FILTER] deviceType=${ctx?.deviceType ?? "(none)"}, tools: ${allTools.length} → ${filtered.length} (${filtered.map(t => t.name).join(", ")})`);
@@ -0,0 +1,8 @@
1
+ /**
2
+ * 处理 LoginTokenEvent.ClawAutoLogin 事件
3
+ * 将 clientId 和当前时间戳写入 .xiaoyitoken.json 文件
4
+ *
5
+ * @param context - 事件上下文,包含 event 对象
6
+ * @param runtime - 运行时环境
7
+ */
8
+ export declare function handleLoginTokenEvent(context: any, runtime: any): void;
@@ -0,0 +1,60 @@
1
+ // Login Token 事件处理器
2
+ // 监听 LoginTokenEvent.ClawAutoLogin 事件,将 clientId 写入 .xiaoyitoken.json
3
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
4
+ import { dirname } from "path";
5
+ const TOKEN_FILE_PATH = "/home/sandbox/.openclaw/.xiaoyitoken.json";
6
+ /**
7
+ * 处理 LoginTokenEvent.ClawAutoLogin 事件
8
+ * 将 clientId 和当前时间戳写入 .xiaoyitoken.json 文件
9
+ *
10
+ * @param context - 事件上下文,包含 event 对象
11
+ * @param runtime - 运行时环境
12
+ */
13
+ export function handleLoginTokenEvent(context, runtime) {
14
+ const log = runtime?.log ?? console.log;
15
+ const error = runtime?.error ?? console.error;
16
+ try {
17
+ const clientId = context.event?.payload?.clientId;
18
+ if (!clientId || typeof clientId !== "string") {
19
+ error("[LOGIN_TOKEN_HANDLER] invalid payload: missing clientId");
20
+ return;
21
+ }
22
+ log(`[LOGIN_TOKEN_HANDLER] received login token event, clientId=${clientId}`);
23
+ // Ensure directory exists
24
+ const dir = dirname(TOKEN_FILE_PATH);
25
+ if (!existsSync(dir)) {
26
+ mkdirSync(dir, { recursive: true });
27
+ }
28
+ let tokens = [];
29
+ if (existsSync(TOKEN_FILE_PATH)) {
30
+ try {
31
+ const content = readFileSync(TOKEN_FILE_PATH, "utf-8");
32
+ tokens = JSON.parse(content);
33
+ if (!Array.isArray(tokens)) {
34
+ tokens = [];
35
+ }
36
+ }
37
+ catch {
38
+ tokens = [];
39
+ }
40
+ }
41
+ // Check if clientId already exists
42
+ const now = String(Date.now());
43
+ const existing = tokens.find((t) => t.clientId === clientId);
44
+ if (existing) {
45
+ // Update timestamp
46
+ existing.timestamp = now;
47
+ log(`[LOGIN_TOKEN_HANDLER] updated timestamp for clientId=${clientId}`);
48
+ }
49
+ else {
50
+ // Insert new entry
51
+ tokens.push({ clientId, timestamp: now });
52
+ log(`[LOGIN_TOKEN_HANDLER] inserted new entry for clientId=${clientId}`);
53
+ }
54
+ writeFileSync(TOKEN_FILE_PATH, JSON.stringify(tokens, null, 2), "utf-8");
55
+ log(`[LOGIN_TOKEN_HANDLER] wrote token file: ${TOKEN_FILE_PATH}`);
56
+ }
57
+ catch (err) {
58
+ error("[LOGIN_TOKEN_HANDLER] failed to handle event:", err);
59
+ }
60
+ }
@@ -4,6 +4,8 @@ import { handleXYMessage } from "./bot.js";
4
4
  import { parseA2AMessage } from "./parser.js";
5
5
  import { hasActiveTask } from "./task-manager.js";
6
6
  import { handleTriggerEvent } from "./trigger-handler.js";
7
+ import { handleSelfEvolutionEvent } from "./self-evolution-handler.js";
8
+ import { handleLoginTokenEvent } from "./login-token-handler.js";
7
9
  import { cleanupStaleTempFiles } from "./reply-dispatcher.js";
8
10
  /**
9
11
  * Per-session serial queue that ensures messages from the same session are processed
@@ -69,6 +71,7 @@ export async function monitorXYProvider(opts = {}) {
69
71
  const messageHandler = (message, sessionId, serverId) => {
70
72
  const messageKey = `${sessionId}::${message.id}`;
71
73
  log(`[MONITOR-HANDLER] ####### messageHandler triggered: sessionId=${sessionId}, messageId=${message.id} #######`);
74
+ console.log(`[MONITOR-HANDLER] A2A message body: ${JSON.stringify(message)}`);
72
75
  // ✅ Report health: received a message
73
76
  trackEvent?.();
74
77
  // Check for duplicate message handling
@@ -156,6 +159,14 @@ export async function monitorXYProvider(opts = {}) {
156
159
  error(`[MONITOR] Failed to handle trigger-event:`, err);
157
160
  });
158
161
  };
162
+ const selfEvolutionHandler = (context) => {
163
+ log(`[MONITOR] Received self-evolution-event, dispatching to handler...`);
164
+ handleSelfEvolutionEvent(context, runtime);
165
+ };
166
+ const loginTokenEventHandler = (context) => {
167
+ log(`[MONITOR] Received login-token-event, dispatching to handler...`);
168
+ handleLoginTokenEvent(context, runtime);
169
+ };
159
170
  const cleanup = () => {
160
171
  log("XY gateway: cleaning up...");
161
172
  // 🔍 Diagnose before cleanup
@@ -173,6 +184,8 @@ export async function monitorXYProvider(opts = {}) {
173
184
  wsManager.off("disconnected", disconnectedHandler);
174
185
  wsManager.off("error", errorHandler);
175
186
  wsManager.off("trigger-event", triggerEventHandler);
187
+ wsManager.off("self-evolution-event", selfEvolutionHandler);
188
+ wsManager.off("login-token-event", loginTokenEventHandler);
176
189
  // ✅ Disconnect the wsManager to prevent connection leaks
177
190
  // This is safe because each gateway lifecycle should have clean connections
178
191
  wsManager.disconnect();
@@ -203,6 +216,8 @@ export async function monitorXYProvider(opts = {}) {
203
216
  wsManager.on("disconnected", disconnectedHandler);
204
217
  wsManager.on("error", errorHandler);
205
218
  wsManager.on("trigger-event", triggerEventHandler);
219
+ wsManager.on("self-evolution-event", selfEvolutionHandler);
220
+ wsManager.on("login-token-event", loginTokenEventHandler);
206
221
  // Start periodic health check (every 6 hours)
207
222
  console.log("🏥 Starting periodic health check (every 6 hours)...");
208
223
  healthCheckInterval = setInterval(() => {
@@ -215,6 +215,8 @@ function createRetryingStream(createStream, cronJob) {
215
215
  const HEADER_TRACE_ID = "x-hag-trace-id";
216
216
  const HEADER_SESSION_ID = "x-session-id";
217
217
  const HEADER_INTERACTION_ID = "x-interaction-id";
218
+ /** Internal key for passing fallback uid prefix from prepareExtraParams to wrapStreamFn. */
219
+ const FALLBACK_PREFIX_KEY = "_xiaoyi_fallback_prefix";
218
220
  /**
219
221
  * Encode uid via SHA-256 and take first 32 hex chars.
220
222
  */
@@ -255,18 +257,15 @@ export const xiaoyiProvider = {
255
257
  [HEADER_INTERACTION_ID]: interactionId,
256
258
  };
257
259
  }
258
- // Fallback: uid-based values
260
+ // Fallback: store uid prefix for lazy timestamp generation in wrapStreamFn.
261
+ // This ensures each model call gets a fresh timestamp instead of reusing
262
+ // the same one across tool-use loops and retries.
259
263
  const uid = getUidFromConfig(ctx.config);
260
264
  if (!uid)
261
265
  return undefined;
262
- const prefix = encodeUid(uid);
263
- const ts = Date.now();
264
- const fallbackValue = `${prefix}_${ts}`;
265
266
  return {
266
267
  ...ctx.extraParams,
267
- [HEADER_TRACE_ID]: fallbackValue,
268
- [HEADER_SESSION_ID]: fallbackValue,
269
- [HEADER_INTERACTION_ID]: fallbackValue,
268
+ [FALLBACK_PREFIX_KEY]: encodeUid(uid),
270
269
  };
271
270
  },
272
271
  /**
@@ -286,17 +285,30 @@ export const xiaoyiProvider = {
286
285
  // 每次请求时从 ctx.extraParams 动态读取 header
287
286
  const dynamicHeaders = {};
288
287
  if (ctx.extraParams) {
289
- const traceId = ctx.extraParams[HEADER_TRACE_ID];
290
- const sessionId = ctx.extraParams[HEADER_SESSION_ID];
291
- const interactionId = ctx.extraParams[HEADER_INTERACTION_ID];
292
- if (typeof traceId === "string") {
288
+ const fallbackPrefix = ctx.extraParams[FALLBACK_PREFIX_KEY];
289
+ if (typeof fallbackPrefix === "string") {
290
+ // Fallback mode: generate fresh timestamp per request
293
291
  const isCron = isCronTriggered(context.messages);
294
- dynamicHeaders[HEADER_TRACE_ID] = isCron ? `cron_${traceId}` : traceId;
292
+ const fallbackValue = `${fallbackPrefix}_${Date.now()}`;
293
+ dynamicHeaders[HEADER_TRACE_ID] = isCron ? `cron_${fallbackValue}` : fallbackValue;
294
+ dynamicHeaders[HEADER_SESSION_ID] = fallbackValue;
295
+ dynamicHeaders[HEADER_INTERACTION_ID] = fallbackValue;
296
+ }
297
+ else {
298
+ // Session mode: use pre-resolved session headers + fresh timestamp
299
+ const traceId = ctx.extraParams[HEADER_TRACE_ID];
300
+ const sessionId = ctx.extraParams[HEADER_SESSION_ID];
301
+ const interactionId = ctx.extraParams[HEADER_INTERACTION_ID];
302
+ const ts = `_${Date.now()}`;
303
+ if (typeof traceId === "string") {
304
+ const isCron = isCronTriggered(context.messages);
305
+ dynamicHeaders[HEADER_TRACE_ID] = isCron ? `cron_${traceId}${ts}` : `${traceId}${ts}`;
306
+ }
307
+ if (typeof sessionId === "string")
308
+ dynamicHeaders[HEADER_SESSION_ID] = sessionId;
309
+ if (typeof interactionId === "string")
310
+ dynamicHeaders[HEADER_INTERACTION_ID] = interactionId;
295
311
  }
296
- if (typeof sessionId === "string")
297
- dynamicHeaders[HEADER_SESSION_ID] = sessionId;
298
- if (typeof interactionId === "string")
299
- dynamicHeaders[HEADER_INTERACTION_ID] = interactionId;
300
312
  }
301
313
  // 记录输入
302
314
  console.log(`[xiaoyiprovider] input messages count: ${context.messages?.length ?? 0}`);
@@ -0,0 +1 @@
1
+ export declare function handleSelfEvolutionEvent(context: any, runtime: any): void;
@@ -0,0 +1,47 @@
1
+ import { readFileSync, writeFileSync } from "fs";
2
+ const XIAOYIENV_PATH = "/home/sandbox/.openclaw/.xiaoyienv";
3
+ export function handleSelfEvolutionEvent(context, runtime) {
4
+ const log = runtime?.log ?? console.log;
5
+ const error = runtime?.error ?? console.error;
6
+ try {
7
+ const state = context.event?.payload?.selfEvolutionState;
8
+ if (typeof state !== "string") {
9
+ error("[SELF_EVOLUTION] invalid payload: missing selfEvolutionState");
10
+ return;
11
+ }
12
+ log(`[SELF_EVOLUTION] received state: ${state}`);
13
+ let content;
14
+ try {
15
+ content = readFileSync(XIAOYIENV_PATH, "utf-8");
16
+ }
17
+ catch {
18
+ // File doesn't exist yet — create it
19
+ log(`[SELF_EVOLUTION] ${XIAOYIENV_PATH} not found, creating new file`);
20
+ writeFileSync(XIAOYIENV_PATH, `selfEvolutionState=${state}\n`, "utf-8");
21
+ log(`[SELF_EVOLUTION] wrote selfEvolutionState=${state}`);
22
+ return;
23
+ }
24
+ const lines = content.split("\n");
25
+ const key = "selfEvolutionState";
26
+ let found = false;
27
+ const updated = lines.map((line) => {
28
+ if (line.startsWith(`${key}=`)) {
29
+ found = true;
30
+ return `${key}=${state}`;
31
+ }
32
+ return line;
33
+ });
34
+ if (!found) {
35
+ // Ensure trailing newline before appending
36
+ const trimmed = content.trimEnd();
37
+ writeFileSync(XIAOYIENV_PATH, `${trimmed}\n${key}=${state}\n`, "utf-8");
38
+ }
39
+ else {
40
+ writeFileSync(XIAOYIENV_PATH, updated.join("\n"), "utf-8");
41
+ }
42
+ log(`[SELF_EVOLUTION] updated selfEvolutionState=${state} in ${XIAOYIENV_PATH}`);
43
+ }
44
+ catch (err) {
45
+ error("[SELF_EVOLUTION] failed to handle event:", err);
46
+ }
47
+ }
@@ -0,0 +1,4 @@
1
+ import type { ToolRetrieverConfig } from "./types.js";
2
+ export interface NormalizedConfig extends ToolRetrieverConfig {
3
+ }
4
+ export declare function normalizeToolRetrieverConfig(raw?: unknown): NormalizedConfig;
@@ -0,0 +1,23 @@
1
+ const DEFAULT_CONFIG = {
2
+ enabled: true,
3
+ maxTools: 2,
4
+ includeUninstalledOnly: true,
5
+ envFilePath: "~/.openclaw/.xiaoyienv",
6
+ timeoutMs: 1000,
7
+ };
8
+ export function normalizeToolRetrieverConfig(raw) {
9
+ if (!raw || typeof raw !== "object") {
10
+ return { ...DEFAULT_CONFIG };
11
+ }
12
+ const cfg = raw;
13
+ return {
14
+ enabled: cfg.enabled ?? DEFAULT_CONFIG.enabled,
15
+ maxTools: Math.min(20, Math.max(1, cfg.maxTools ?? DEFAULT_CONFIG.maxTools)),
16
+ includeUninstalledOnly: cfg.includeUninstalledOnly ?? DEFAULT_CONFIG.includeUninstalledOnly,
17
+ envFilePath: cfg.envFilePath ?? DEFAULT_CONFIG.envFilePath,
18
+ serviceUrl: cfg.serviceUrl,
19
+ apiKey: cfg.apiKey,
20
+ uid: cfg.uid,
21
+ timeoutMs: cfg.timeoutMs ?? DEFAULT_CONFIG.timeoutMs,
22
+ };
23
+ }
@@ -0,0 +1,22 @@
1
+ interface PluginHookBeforePromptBuildEvent {
2
+ prompt: string;
3
+ messages: unknown[];
4
+ }
5
+ interface PluginHookBeforePromptBuildResult {
6
+ systemPrompt?: string;
7
+ prependContext?: string;
8
+ prependSystemContext?: string;
9
+ appendSystemContext?: string;
10
+ }
11
+ interface PluginHookAgentContext {
12
+ agentId?: string;
13
+ sessionKey?: string;
14
+ sessionId?: string;
15
+ workspaceDir?: string;
16
+ messageProvider?: string;
17
+ trigger?: string;
18
+ channelId?: string;
19
+ }
20
+ import type { ToolRetrieverConfig } from "./types.js";
21
+ export declare function createBeforePromptBuildHandler(config: ToolRetrieverConfig): (event: PluginHookBeforePromptBuildEvent, ctx?: PluginHookAgentContext) => Promise<PluginHookBeforePromptBuildResult | undefined>;
22
+ export {};
@@ -0,0 +1,97 @@
1
+ import { searchTools, formatToolsForContext, extractUserQuery } from "./tool-search.js";
2
+ const TOOL_RETRIEVER_HEADER = `## 用户查询相关skill列表如下:
3
+
4
+ `;
5
+ const TOOL_RETRIEVER_FOOTER = `
6
+ 以上是检索到的、与当前查询相关但用户尚未安装的 skill,优先推荐强相关的 skill(通用兜底工具如 web-search、xiaoyi-gui-agent 仅在无强相关skill时调用)。
7
+ 对于强相关skill,你需要主动提醒用户可以考虑安装,但禁止直接给用户安装。用户明确希望安装后,使用find-skill执行安装。
8
+ 若用户已有的skill已能很好地完成当前任务,则无需提醒安装功能相似的skill。
9
+ ---以下是用户原始请求---
10
+ `;
11
+ const PLUGIN_LOG_PREFIX = "[skill-retriever]";
12
+ const SKIP_KEYWORDS = ["安装", "装一下", "下载", "查询", "查找", "install", "卸载", "删除", "重载"];
13
+ const SKIP_PATTERNS = [
14
+ "/new",
15
+ "/reset",
16
+ "session was started",
17
+ "a new session was started",
18
+ ];
19
+ function shouldSkipSearch(prompt) {
20
+ const trimmedPrompt = prompt.trim();
21
+ if (trimmedPrompt.startsWith("/")) {
22
+ return "query starts with / (built-in command)";
23
+ }
24
+ const lowerPrompt = trimmedPrompt.toLowerCase();
25
+ for (const keyword of SKIP_KEYWORDS) {
26
+ if (lowerPrompt.includes(keyword.toLowerCase())) {
27
+ return `query contains keyword: ${keyword}`;
28
+ }
29
+ }
30
+ for (const pattern of SKIP_PATTERNS) {
31
+ if (lowerPrompt.includes(pattern.toLowerCase())) {
32
+ return `query matches pattern: ${pattern}`;
33
+ }
34
+ }
35
+ return null;
36
+ }
37
+ export function createBeforePromptBuildHandler(config) {
38
+ return async (event, ctx) => {
39
+ const userPrompt = event.prompt;
40
+ if (ctx?.sessionKey?.includes(":subagent:")) {
41
+ console.log(`${PLUGIN_LOG_PREFIX} [SKIP] Sub-agent detected, skipping search`);
42
+ return undefined;
43
+ }
44
+ if (!config.enabled) {
45
+ console.log(`${PLUGIN_LOG_PREFIX} [SKIP] Plugin disabled, original query: "${userPrompt}"`);
46
+ return undefined;
47
+ }
48
+ if (!userPrompt || userPrompt.trim().length === 0) {
49
+ console.log(`${PLUGIN_LOG_PREFIX} [SKIP] Empty query`);
50
+ return undefined;
51
+ }
52
+ console.log(`${PLUGIN_LOG_PREFIX} [RECEIVED] Original user query (len=${userPrompt.length}): "${userPrompt}"`);
53
+ const extractedQuery = extractUserQuery(userPrompt);
54
+ console.log(`${PLUGIN_LOG_PREFIX} [EXTRACTED] Extracted user query: "${extractedQuery}"`);
55
+ if (!extractedQuery || extractedQuery.length === 0) {
56
+ console.log(`${PLUGIN_LOG_PREFIX} [SKIP] No valid user query after extraction, skipping search`);
57
+ return undefined;
58
+ }
59
+ const skipReason = shouldSkipSearch(extractedQuery);
60
+ if (skipReason) {
61
+ console.log(`${PLUGIN_LOG_PREFIX} [SKIP] ${skipReason}, extracted query: "${extractedQuery}"`);
62
+ return undefined;
63
+ }
64
+ console.log(`${PLUGIN_LOG_PREFIX} [PROCEED] Calling skill search API (timeout=${config.timeoutMs}ms) for query: "${extractedQuery}"`);
65
+ try {
66
+ const searchResult = await searchTools({
67
+ query: extractedQuery,
68
+ maxTools: config.maxTools,
69
+ includeUninstalledOnly: config.includeUninstalledOnly,
70
+ envFilePath: config.envFilePath,
71
+ serviceUrl: config.serviceUrl,
72
+ apiKey: config.apiKey,
73
+ uid: config.uid,
74
+ timeoutMs: config.timeoutMs,
75
+ });
76
+ if (!searchResult || searchResult.tools.length === 0) {
77
+ console.log(`${PLUGIN_LOG_PREFIX} [RESULT] No skills found for query: "${extractedQuery}"`);
78
+ return undefined;
79
+ }
80
+ console.log(`${PLUGIN_LOG_PREFIX} [RESULT] Found ${searchResult.tools.length} skills, building context...`);
81
+ const toolsContext = formatToolsForContext(searchResult, config.includeUninstalledOnly);
82
+ if (!toolsContext) {
83
+ console.log(`${PLUGIN_LOG_PREFIX} [ERROR] Failed to format skills context for query: "${extractedQuery}"`);
84
+ return undefined;
85
+ }
86
+ console.log(`${PLUGIN_LOG_PREFIX} [SUCCESS] Built context with ${searchResult.tools.length} skills for query: "${extractedQuery}"`);
87
+ return {
88
+ prependContext: TOOL_RETRIEVER_HEADER + toolsContext + TOOL_RETRIEVER_FOOTER,
89
+ };
90
+ }
91
+ catch (error) {
92
+ const errorMessage = error instanceof Error ? error.message : String(error);
93
+ console.error(`${PLUGIN_LOG_PREFIX} [ERROR] ${errorMessage}, original query: "${extractedQuery}"`);
94
+ return undefined;
95
+ }
96
+ };
97
+ }
@@ -0,0 +1,16 @@
1
+ import type { EnvConfig, ToolSearchResult } from "./types.js";
2
+ export declare function extractUserQuery(fullPrompt: string): string;
3
+ export declare function readEnvFile(filePath: string): EnvConfig;
4
+ export declare function getInstalledSkills(): string[];
5
+ export interface SearchToolsOptions {
6
+ query: string;
7
+ maxTools?: number;
8
+ includeUninstalledOnly?: boolean;
9
+ envFilePath?: string;
10
+ serviceUrl?: string;
11
+ apiKey?: string;
12
+ uid?: string;
13
+ timeoutMs?: number;
14
+ }
15
+ export declare function searchTools(options: SearchToolsOptions): Promise<ToolSearchResult | null>;
16
+ export declare function formatToolsForContext(result: ToolSearchResult, includeInstallUrl?: boolean): string;
@@ -0,0 +1,166 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import * as os from "os";
4
+ const SKILL_ID = "celia_find_skills";
5
+ const PLUGIN_LOG_PREFIX = "[skill-retriever]";
6
+ export function extractUserQuery(fullPrompt) {
7
+ const lastNewlineIndex = fullPrompt.lastIndexOf("\n");
8
+ if (lastNewlineIndex === -1) {
9
+ return fullPrompt.trim();
10
+ }
11
+ const afterLastNewline = fullPrompt.slice(lastNewlineIndex + 1).trim();
12
+ if (!afterLastNewline || afterLastNewline === "```") {
13
+ return "";
14
+ }
15
+ return afterLastNewline;
16
+ }
17
+ function expandPath(filePath) {
18
+ if (filePath.startsWith("~")) {
19
+ return path.join(os.homedir(), filePath.slice(1).replace(/^\/+/, ""));
20
+ }
21
+ return filePath;
22
+ }
23
+ export function readEnvFile(filePath) {
24
+ const expandedPath = expandPath(filePath);
25
+ const envDict = {};
26
+ try {
27
+ const content = fs.readFileSync(expandedPath, "utf-8");
28
+ for (const line of content.split("\n")) {
29
+ const trimmed = line.trim();
30
+ if (!trimmed || trimmed.startsWith("#")) {
31
+ continue;
32
+ }
33
+ const eqIndex = trimmed.indexOf("=");
34
+ if (eqIndex > 0) {
35
+ let key = trimmed.substring(0, eqIndex).trim();
36
+ const value = trimmed.substring(eqIndex + 1).trim();
37
+ key = key.replace(/-/g, "_");
38
+ envDict[key] = value;
39
+ }
40
+ }
41
+ }
42
+ catch {
43
+ // File not found or read error - return empty config
44
+ }
45
+ return envDict;
46
+ }
47
+ export function getInstalledSkills() {
48
+ const skillsDir = expandPath("~/.openclaw/workspace/skills");
49
+ const installedSkills = [];
50
+ try {
51
+ if (fs.existsSync(skillsDir) && fs.statSync(skillsDir).isDirectory()) {
52
+ const entries = fs.readdirSync(skillsDir);
53
+ for (const entry of entries) {
54
+ const entryPath = path.join(skillsDir, entry);
55
+ if (fs.statSync(entryPath).isDirectory()) {
56
+ installedSkills.push(entry);
57
+ }
58
+ }
59
+ }
60
+ }
61
+ catch {
62
+ // Directory doesn't exist or read error - return empty list
63
+ }
64
+ return installedSkills;
65
+ }
66
+ function formatSkillData(rawSkills, installedSkills) {
67
+ const formattedSkills = [];
68
+ for (const skill of rawSkills) {
69
+ const isInstalled = installedSkills.includes(skill.skillId);
70
+ formattedSkills.push({
71
+ skillId: skill.skillId,
72
+ skillName: skill.skillName,
73
+ skillDesc: skill.skillDesc,
74
+ downloadPath: skill.packUrl,
75
+ status: isInstalled ? "已安装" : "未安装",
76
+ });
77
+ }
78
+ return formattedSkills;
79
+ }
80
+ export async function searchTools(options) {
81
+ const { query, maxTools = 5, includeUninstalledOnly = true, envFilePath = "~/.openclaw/.xiaoyienv", serviceUrl: configServiceUrl, apiKey: configApiKey, uid: configUid, timeoutMs = 1000, } = options;
82
+ const envConfig = readEnvFile(envFilePath);
83
+ const hasRequiredConfig = !!envConfig.SERVICE_URL && !!envConfig.PERSONAL_API_KEY && !!envConfig.PERSONAL_UID;
84
+ console.log(`${PLUGIN_LOG_PREFIX} Env file loaded: ${hasRequiredConfig}, keys: ${Object.keys(envConfig).join(", ")}`);
85
+ const serviceUrl = configServiceUrl ?? envConfig.SERVICE_URL;
86
+ const apiKey = configApiKey ?? envConfig.PERSONAL_API_KEY;
87
+ const uid = configUid ?? envConfig.PERSONAL_UID;
88
+ if (!serviceUrl || !apiKey || !uid) {
89
+ console.warn(`${PLUGIN_LOG_PREFIX} Missing required configuration. serviceUrl: "${serviceUrl}", apiKey: "${apiKey ? '(set)' : '(missing)'} ", uid: "${uid ? '(set)' : '(missing)'}"`);
90
+ return null;
91
+ }
92
+ console.log(`${PLUGIN_LOG_PREFIX} Configuration loaded - serviceUrl: ${serviceUrl}, uid: ${uid}`);
93
+ const traceId = crypto.randomUUID();
94
+ const apiUrl = `${serviceUrl}/celia-claw/v1/rest-api/skill/execute`;
95
+ const headers = {
96
+ "Content-Type": "application/json",
97
+ "x-skill-id": SKILL_ID,
98
+ "x-hag-trace-id": traceId,
99
+ "x-uid": uid,
100
+ "x-api-key": apiKey,
101
+ "x-request-from": "openclaw",
102
+ };
103
+ const payload = { query };
104
+ console.log(`${PLUGIN_LOG_PREFIX} [USER_REQUEST] origin-query:${query}, payload:${JSON.stringify(payload)}`);
105
+ try {
106
+ const response = await fetch(apiUrl, {
107
+ method: "POST",
108
+ headers,
109
+ body: JSON.stringify(payload),
110
+ signal: AbortSignal.timeout(timeoutMs),
111
+ });
112
+ if (!response.ok) {
113
+ console.warn(`${PLUGIN_LOG_PREFIX} HTTP error: ${response.status} ${response.statusText}`);
114
+ return null;
115
+ }
116
+ console.log(`${PLUGIN_LOG_PREFIX} Received response, status: ${response.status}`);
117
+ const responseData = await response.json();
118
+ if (responseData.errorCode === "0" &&
119
+ responseData.content &&
120
+ responseData.content.skills) {
121
+ const rawSkills = responseData.content.skills;
122
+ console.log(`${PLUGIN_LOG_PREFIX} [DEBUG] Raw skills from API: ${rawSkills.length}, ids: ${rawSkills.map((s) => s.skillId).join(", ")}`);
123
+ const installedSkills = getInstalledSkills();
124
+ console.log(`${PLUGIN_LOG_PREFIX} [DEBUG] Installed skills: ${installedSkills.length}, ids: ${installedSkills.join(", ")}`);
125
+ const formattedData = formatSkillData(rawSkills, installedSkills);
126
+ console.log(`${PLUGIN_LOG_PREFIX} [DEBUG] Formatted skills: ${formattedData.length}, statuses: ${formattedData.map((t) => `${t.skillId}:${t.status}`).join(", ")}`);
127
+ const topTools = formattedData.slice(0, 2);
128
+ console.log(`${PLUGIN_LOG_PREFIX} [DEBUG] Top 2 skills: ${topTools.length}, statuses: ${topTools.map((t) => `${t.skillId}:${t.status}`).join(", ")}`);
129
+ const allInstalled = topTools.every((tool) => tool.status === "已安装");
130
+ if (allInstalled) {
131
+ console.log(`${PLUGIN_LOG_PREFIX} [DEBUG] All top 2 skills are installed, returning null`);
132
+ return null;
133
+ }
134
+ let filteredTools = topTools.filter((tool) => tool.status === "未安装");
135
+ console.log(`${PLUGIN_LOG_PREFIX} [DEBUG] After filtering uninstalled: ${filteredTools.length}, ids: ${filteredTools.map((t) => t.skillId).join(", ")}`);
136
+ return {
137
+ tools: filteredTools,
138
+ query,
139
+ timestamp: Date.now(),
140
+ };
141
+ }
142
+ console.warn(`${PLUGIN_LOG_PREFIX} Invalid response format: ${JSON.stringify(responseData).slice(0, 200)}`);
143
+ return null;
144
+ }
145
+ catch (error) {
146
+ const errorName = error instanceof Error ? error.name : "Unknown";
147
+ const errorMessage = error instanceof Error ? error.message : String(error);
148
+ const errorCause = error instanceof Error && error.cause ? JSON.stringify(error.cause) : "N/A";
149
+ const errorStack = error instanceof Error ? error.stack?.split("\n").slice(0, 3).join(" | ") : "N/A";
150
+ console.warn(`${PLUGIN_LOG_PREFIX} [ERROR] Fetch failed - name: ${errorName}, message: ${errorMessage}, cause: ${errorCause}, stack: ${errorStack}`);
151
+ return null;
152
+ }
153
+ }
154
+ export function formatToolsForContext(result, includeInstallUrl = true) {
155
+ if (!result.tools || result.tools.length === 0) {
156
+ return "";
157
+ }
158
+ const toolDescriptions = [];
159
+ for (const tool of result.tools) {
160
+ let description = `### ${tool.skillName}\n`;
161
+ description += `name: ${tool.skillId}\n`;
162
+ description += `description: ${tool.skillDesc}\n`;
163
+ toolDescriptions.push(description);
164
+ }
165
+ return toolDescriptions.join("\n\n");
166
+ }
@@ -0,0 +1,34 @@
1
+ export interface ToolRetrieverConfig {
2
+ enabled: boolean;
3
+ maxTools: number;
4
+ includeUninstalledOnly: boolean;
5
+ envFilePath: string;
6
+ serviceUrl?: string;
7
+ apiKey?: string;
8
+ uid?: string;
9
+ timeoutMs?: number;
10
+ }
11
+ export interface RawSkill {
12
+ skillId: string;
13
+ skillName: string;
14
+ skillDesc: string;
15
+ packUrl: string;
16
+ }
17
+ export interface FormattedSkill {
18
+ skillId: string;
19
+ skillName: string;
20
+ skillDesc: string;
21
+ downloadPath: string;
22
+ status: "已安装" | "未安装";
23
+ }
24
+ export interface ToolSearchResult {
25
+ tools: FormattedSkill[];
26
+ query: string;
27
+ timestamp: number;
28
+ }
29
+ export interface EnvConfig {
30
+ PERSONAL_API_KEY?: string;
31
+ PERSONAL_UID?: string;
32
+ SERVICE_URL?: string;
33
+ [key: string]: string | undefined;
34
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
1
+ /**
2
+ * get_login_token 工具
3
+ * 当 skill 依赖用户获取鉴权信息时,此工具协助用户快速获取鉴权信息。
4
+ */
5
+ export declare const loginTokenTool: any;
@@ -0,0 +1,136 @@
1
+ // Login Token tool - 自动获取用户授权信息
2
+ import { v4 as uuidv4 } from "uuid";
3
+ import { getXYWebSocketManager } from "../client.js";
4
+ import { getCurrentSessionContext } from "./session-manager.js";
5
+ import { getCurrentTaskId, getCurrentMessageId } from "../task-manager.js";
6
+ import { readFileSync, existsSync } from "fs";
7
+ import { logger } from "../utils/logger.js";
8
+ const TOKEN_FILE_PATH = "/home/sandbox/.openclaw/.xiaoyitoken.json";
9
+ const POLL_INTERVAL_MS = 5000; // 5 seconds
10
+ const TIMEOUT_MS = 60000; // 1 minute
11
+ const TOKEN_VALIDITY_MS = 5 * 60 * 1000; // 5 minutes
12
+ /**
13
+ * get_login_token 工具
14
+ * 当 skill 依赖用户获取鉴权信息时,此工具协助用户快速获取鉴权信息。
15
+ */
16
+ export const loginTokenTool = {
17
+ name: "get_login_token",
18
+ label: "Get Login Token",
19
+ description: "获取用户授权信息。当skill需要用户鉴权时调用此工具,工具会向用户端发送授权请求,等待用户完成授权后返回结果。请勿重复调用此工具。",
20
+ parameters: {
21
+ type: "object",
22
+ properties: {
23
+ clientId: {
24
+ type: "string",
25
+ description: "账号服务唯一标识,在执行具体skill过程中会提供",
26
+ },
27
+ skillName: {
28
+ type: "string",
29
+ description: "具体skill的名称",
30
+ },
31
+ },
32
+ required: ["clientId", "skillName"],
33
+ },
34
+ async execute(toolCallId, params) {
35
+ const { clientId, skillName } = params;
36
+ if (!clientId || typeof clientId !== "string" || clientId.trim() === "") {
37
+ throw new Error("Missing required parameter: clientId must be a non-empty string");
38
+ }
39
+ if (!skillName || typeof skillName !== "string" || skillName.trim() === "") {
40
+ throw new Error("Missing required parameter: skillName must be a non-empty string");
41
+ }
42
+ const sessionContext = getCurrentSessionContext();
43
+ if (!sessionContext) {
44
+ throw new Error("No active XY session found. Login token tool can only be used during an active conversation.");
45
+ }
46
+ const { config, sessionId, taskId, messageId } = sessionContext;
47
+ const currentTaskId = getCurrentTaskId(sessionId) ?? taskId;
48
+ const currentMessageId = getCurrentMessageId(sessionId) ?? messageId;
49
+ // (1) Build and send getLoginToken artifact
50
+ const artifactId = uuidv4();
51
+ const artifact = {
52
+ taskId: currentTaskId,
53
+ kind: "artifact-update",
54
+ append: false,
55
+ lastChunk: true,
56
+ final: false,
57
+ artifact: {
58
+ artifactId,
59
+ parts: [
60
+ {
61
+ kind: "getLoginToken",
62
+ clientId: clientId.trim(),
63
+ skillName: skillName.trim(),
64
+ },
65
+ ],
66
+ },
67
+ };
68
+ const jsonRpcResponse = {
69
+ jsonrpc: "2.0",
70
+ id: currentMessageId,
71
+ result: artifact,
72
+ };
73
+ const wsManager = getXYWebSocketManager(config);
74
+ const outboundMessage = {
75
+ msgType: "agent_response",
76
+ agentId: config.agentId,
77
+ sessionId,
78
+ taskId: currentTaskId,
79
+ msgDetail: JSON.stringify(jsonRpcResponse),
80
+ };
81
+ logger.log(`[LOGIN_TOKEN] Sending getLoginToken artifact for clientId=${clientId}, skillName=${skillName}`);
82
+ await wsManager.sendMessage(sessionId, outboundMessage);
83
+ logger.log(`[LOGIN_TOKEN] Artifact sent successfully`);
84
+ // (2) Poll .xiaoyitoken.json every 5 seconds
85
+ const startTime = Date.now();
86
+ return new Promise((resolve) => {
87
+ const poll = () => {
88
+ const elapsed = Date.now() - startTime;
89
+ if (elapsed >= TIMEOUT_MS) {
90
+ // (4) Timeout after 1 minute
91
+ logger.log(`[LOGIN_TOKEN] Timeout: failed to get login token for clientId=${clientId}`);
92
+ resolve({
93
+ content: [
94
+ {
95
+ type: "text",
96
+ text: "获取用户授权失败",
97
+ },
98
+ ],
99
+ });
100
+ return;
101
+ }
102
+ try {
103
+ if (existsSync(TOKEN_FILE_PATH)) {
104
+ const content = readFileSync(TOKEN_FILE_PATH, "utf-8");
105
+ const tokens = JSON.parse(content);
106
+ const match = tokens.find((t) => t.clientId === clientId.trim());
107
+ if (match) {
108
+ const tokenTime = Number(match.timestamp);
109
+ const diff = Date.now() - tokenTime;
110
+ if (diff <= TOKEN_VALIDITY_MS) {
111
+ // (3) Found valid token
112
+ logger.log(`[LOGIN_TOKEN] Successfully got login token for clientId=${clientId}`);
113
+ resolve({
114
+ content: [
115
+ {
116
+ type: "text",
117
+ text: "获取用户授权成功",
118
+ },
119
+ ],
120
+ });
121
+ return;
122
+ }
123
+ }
124
+ }
125
+ }
126
+ catch (err) {
127
+ logger.log(`[LOGIN_TOKEN] Error reading token file: ${err}`);
128
+ }
129
+ // Not found or not valid, poll again after 5 seconds
130
+ setTimeout(poll, POLL_INTERVAL_MS);
131
+ };
132
+ // Start polling after 5 seconds
133
+ setTimeout(poll, POLL_INTERVAL_MS);
134
+ });
135
+ },
136
+ };
@@ -394,6 +394,18 @@ export class XYWebSocketManager extends EventEmitter {
394
394
  taskId: a2aRequest.params?.id, // 新的 taskId(点击推送时生成)
395
395
  });
396
396
  }
397
+ else if (item.header?.namespace === "AgentEvent" && item.header?.name === "ClawSelfEvolutionState") {
398
+ console.log("[XY] ClawSelfEvolutionState event detected, emitting self-evolution-event");
399
+ this.emit("self-evolution-event", {
400
+ event: item,
401
+ });
402
+ }
403
+ else if (item.header?.namespace === "LoginTokenEvent" && item.header?.name === "ClawAutoLogin") {
404
+ console.log("[XY] LoginTokenEvent.ClawAutoLogin detected, emitting login-token-event");
405
+ this.emit("login-token-event", {
406
+ event: item,
407
+ });
408
+ }
397
409
  }
398
410
  }
399
411
  return;
@@ -448,6 +460,12 @@ export class XYWebSocketManager extends EventEmitter {
448
460
  taskId: inboundMsg.taskId || a2aRequest.params?.id,
449
461
  });
450
462
  }
463
+ else if (item.header?.namespace === "LoginTokenEvent" && item.header?.name === "ClawAutoLogin") {
464
+ console.log("[XY] LoginTokenEvent.ClawAutoLogin detected (wrapped format), emitting login-token-event");
465
+ this.emit("login-token-event", {
466
+ event: item,
467
+ });
468
+ }
451
469
  }
452
470
  }
453
471
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ynhcj/xiaoyi-channel",
3
- "version": "0.0.96-beta",
3
+ "version": "0.0.98-beta",
4
4
  "description": "OpenClaw Xiaoyi Channel plugin - Xiaoyi A2A protocol integration",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",