@ynhcj/xiaoyi-channel 0.0.97-beta → 0.0.99-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
+ }
@@ -5,6 +5,7 @@ import { parseA2AMessage } from "./parser.js";
5
5
  import { hasActiveTask } from "./task-manager.js";
6
6
  import { handleTriggerEvent } from "./trigger-handler.js";
7
7
  import { handleSelfEvolutionEvent } from "./self-evolution-handler.js";
8
+ import { handleLoginTokenEvent } from "./login-token-handler.js";
8
9
  import { cleanupStaleTempFiles } from "./reply-dispatcher.js";
9
10
  /**
10
11
  * Per-session serial queue that ensures messages from the same session are processed
@@ -161,6 +162,10 @@ export async function monitorXYProvider(opts = {}) {
161
162
  log(`[MONITOR] Received self-evolution-event, dispatching to handler...`);
162
163
  handleSelfEvolutionEvent(context, runtime);
163
164
  };
165
+ const loginTokenEventHandler = (context) => {
166
+ log(`[MONITOR] Received login-token-event, dispatching to handler...`);
167
+ handleLoginTokenEvent(context, runtime);
168
+ };
164
169
  const cleanup = () => {
165
170
  log("XY gateway: cleaning up...");
166
171
  // 🔍 Diagnose before cleanup
@@ -179,6 +184,7 @@ export async function monitorXYProvider(opts = {}) {
179
184
  wsManager.off("error", errorHandler);
180
185
  wsManager.off("trigger-event", triggerEventHandler);
181
186
  wsManager.off("self-evolution-event", selfEvolutionHandler);
187
+ wsManager.off("login-token-event", loginTokenEventHandler);
182
188
  // ✅ Disconnect the wsManager to prevent connection leaks
183
189
  // This is safe because each gateway lifecycle should have clean connections
184
190
  wsManager.disconnect();
@@ -210,6 +216,7 @@ export async function monitorXYProvider(opts = {}) {
210
216
  wsManager.on("error", errorHandler);
211
217
  wsManager.on("trigger-event", triggerEventHandler);
212
218
  wsManager.on("self-evolution-event", selfEvolutionHandler);
219
+ wsManager.on("login-token-event", loginTokenEventHandler);
213
220
  // Start periodic health check (every 6 hours)
214
221
  console.log("🏥 Starting periodic health check (every 6 hours)...");
215
222
  healthCheckInterval = setInterval(() => {
@@ -41,6 +41,25 @@ function isCronTriggered(messages) {
41
41
  }
42
42
  return /^\[cron:/i.test(text.trim());
43
43
  }
44
+ /** Extract cron title from first user message matching `[cron:<uuid> <title>]`. */
45
+ function extractCronTitle(messages) {
46
+ if (!messages)
47
+ return undefined;
48
+ const firstUser = messages.find(m => m.role === "user");
49
+ if (!firstUser)
50
+ return undefined;
51
+ let text = "";
52
+ if (typeof firstUser.content === "string") {
53
+ text = firstUser.content;
54
+ }
55
+ else if (Array.isArray(firstUser.content)) {
56
+ const block = firstUser.content.find(b => b.type === "text" && typeof b.text === "string");
57
+ if (block)
58
+ text = block.text;
59
+ }
60
+ const match = text.trim().match(/^\[cron:[^\s]+\s+(.+)\]$/);
61
+ return match ? match[1] : undefined;
62
+ }
44
63
  /** Compute retry delay in ms for the given 1-based attempt, with up to 10s jitter. */
45
64
  function getRetryDelayMs(attempt, isCron = false) {
46
65
  if (isCron) {
@@ -215,6 +234,8 @@ function createRetryingStream(createStream, cronJob) {
215
234
  const HEADER_TRACE_ID = "x-hag-trace-id";
216
235
  const HEADER_SESSION_ID = "x-session-id";
217
236
  const HEADER_INTERACTION_ID = "x-interaction-id";
237
+ /** Internal key for passing fallback uid prefix from prepareExtraParams to wrapStreamFn. */
238
+ const FALLBACK_PREFIX_KEY = "_xiaoyi_fallback_prefix";
218
239
  /**
219
240
  * Encode uid via SHA-256 and take first 32 hex chars.
220
241
  */
@@ -255,18 +276,15 @@ export const xiaoyiProvider = {
255
276
  [HEADER_INTERACTION_ID]: interactionId,
256
277
  };
257
278
  }
258
- // Fallback: uid-based values
279
+ // Fallback: store uid prefix for lazy timestamp generation in wrapStreamFn.
280
+ // This ensures each model call gets a fresh timestamp instead of reusing
281
+ // the same one across tool-use loops and retries.
259
282
  const uid = getUidFromConfig(ctx.config);
260
283
  if (!uid)
261
284
  return undefined;
262
- const prefix = encodeUid(uid);
263
- const ts = Date.now();
264
- const fallbackValue = `${prefix}_${ts}`;
265
285
  return {
266
286
  ...ctx.extraParams,
267
- [HEADER_TRACE_ID]: fallbackValue,
268
- [HEADER_SESSION_ID]: fallbackValue,
269
- [HEADER_INTERACTION_ID]: fallbackValue,
287
+ [FALLBACK_PREFIX_KEY]: encodeUid(uid),
270
288
  };
271
289
  },
272
290
  /**
@@ -286,17 +304,40 @@ export const xiaoyiProvider = {
286
304
  // 每次请求时从 ctx.extraParams 动态读取 header
287
305
  const dynamicHeaders = {};
288
306
  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") {
307
+ const fallbackPrefix = ctx.extraParams[FALLBACK_PREFIX_KEY];
308
+ if (typeof fallbackPrefix === "string") {
309
+ // Fallback mode: generate fresh timestamp per request
293
310
  const isCron = isCronTriggered(context.messages);
294
- dynamicHeaders[HEADER_TRACE_ID] = isCron ? `cron_${traceId}` : traceId;
311
+ const fallbackValue = `${fallbackPrefix}_${Date.now()}`;
312
+ dynamicHeaders[HEADER_TRACE_ID] = isCron ? `cron_${fallbackValue}` : fallbackValue;
313
+ dynamicHeaders[HEADER_SESSION_ID] = fallbackValue;
314
+ dynamicHeaders[HEADER_INTERACTION_ID] = fallbackValue;
315
+ if (isCron) {
316
+ const cronTitle = extractCronTitle(context.messages);
317
+ if (cronTitle)
318
+ dynamicHeaders["x-cron-title"] = cronTitle;
319
+ }
320
+ }
321
+ else {
322
+ // Session mode: use pre-resolved session headers + fresh timestamp
323
+ const traceId = ctx.extraParams[HEADER_TRACE_ID];
324
+ const sessionId = ctx.extraParams[HEADER_SESSION_ID];
325
+ const interactionId = ctx.extraParams[HEADER_INTERACTION_ID];
326
+ const ts = `_${Date.now()}`;
327
+ if (typeof traceId === "string") {
328
+ const isCron = isCronTriggered(context.messages);
329
+ dynamicHeaders[HEADER_TRACE_ID] = isCron ? `cron_${traceId}${ts}` : `${traceId}${ts}`;
330
+ if (isCron) {
331
+ const cronTitle = extractCronTitle(context.messages);
332
+ if (cronTitle)
333
+ dynamicHeaders["x-cron-title"] = cronTitle;
334
+ }
335
+ }
336
+ if (typeof sessionId === "string")
337
+ dynamicHeaders[HEADER_SESSION_ID] = sessionId;
338
+ if (typeof interactionId === "string")
339
+ dynamicHeaders[HEADER_INTERACTION_ID] = interactionId;
295
340
  }
296
- if (typeof sessionId === "string")
297
- dynamicHeaders[HEADER_SESSION_ID] = sessionId;
298
- if (typeof interactionId === "string")
299
- dynamicHeaders[HEADER_INTERACTION_ID] = interactionId;
300
341
  }
301
342
  // 记录输入
302
343
  console.log(`[xiaoyiprovider] input messages count: ${context.messages?.length ?? 0}`);
@@ -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,87 @@
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
+ return undefined;
42
+ }
43
+ if (!config.enabled) {
44
+ return undefined;
45
+ }
46
+ if (!userPrompt || userPrompt.trim().length === 0) {
47
+ return undefined;
48
+ }
49
+ const extractedQuery = extractUserQuery(userPrompt);
50
+ if (!extractedQuery || extractedQuery.length === 0) {
51
+ return undefined;
52
+ }
53
+ const skipReason = shouldSkipSearch(extractedQuery);
54
+ if (skipReason) {
55
+ return undefined;
56
+ }
57
+ try {
58
+ const searchResult = await searchTools({
59
+ query: extractedQuery,
60
+ maxTools: config.maxTools,
61
+ includeUninstalledOnly: config.includeUninstalledOnly,
62
+ envFilePath: config.envFilePath,
63
+ serviceUrl: config.serviceUrl,
64
+ apiKey: config.apiKey,
65
+ uid: config.uid,
66
+ timeoutMs: config.timeoutMs,
67
+ });
68
+ if (!searchResult || searchResult.tools.length === 0) {
69
+ return undefined;
70
+ }
71
+ console.log(`${PLUGIN_LOG_PREFIX} [RESULT] Found ${searchResult.tools.length} skills, building context...`);
72
+ const toolsContext = formatToolsForContext(searchResult, config.includeUninstalledOnly);
73
+ if (!toolsContext) {
74
+ console.log(`${PLUGIN_LOG_PREFIX} [ERROR] Failed to format skills context`);
75
+ return undefined;
76
+ }
77
+ return {
78
+ prependContext: TOOL_RETRIEVER_HEADER + toolsContext + TOOL_RETRIEVER_FOOTER,
79
+ };
80
+ }
81
+ catch (error) {
82
+ const errorMessage = error instanceof Error ? error.message : String(error);
83
+ console.error(`${PLUGIN_LOG_PREFIX} [ERROR] ${errorMessage}, original query: "${extractedQuery}"`);
84
+ return undefined;
85
+ }
86
+ };
87
+ }
@@ -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,159 @@
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
+ const serviceUrl = configServiceUrl ?? envConfig.SERVICE_URL;
85
+ const apiKey = configApiKey ?? envConfig.PERSONAL_API_KEY;
86
+ const uid = configUid ?? envConfig.PERSONAL_UID;
87
+ if (!serviceUrl || !apiKey || !uid) {
88
+ console.warn(`${PLUGIN_LOG_PREFIX} Missing required configuration. serviceUrl: "${serviceUrl}", apiKey: "${apiKey ? '(set)' : '(missing)'} ", uid: "${uid ? '(set)' : '(missing)'}"`);
89
+ return null;
90
+ }
91
+ const traceId = crypto.randomUUID();
92
+ const apiUrl = `${serviceUrl}/celia-claw/v1/rest-api/skill/execute`;
93
+ const headers = {
94
+ "Content-Type": "application/json",
95
+ "x-skill-id": SKILL_ID,
96
+ "x-hag-trace-id": traceId,
97
+ "x-uid": uid,
98
+ "x-api-key": apiKey,
99
+ "x-request-from": "openclaw",
100
+ };
101
+ const payload = { query };
102
+ try {
103
+ const response = await fetch(apiUrl, {
104
+ method: "POST",
105
+ headers,
106
+ body: JSON.stringify(payload),
107
+ signal: AbortSignal.timeout(timeoutMs),
108
+ });
109
+ if (!response.ok) {
110
+ console.warn(`${PLUGIN_LOG_PREFIX} HTTP error: ${response.status} ${response.statusText}`);
111
+ return null;
112
+ }
113
+ console.log(`${PLUGIN_LOG_PREFIX} Received response, status: ${response.status}`);
114
+ const responseData = await response.json();
115
+ if (responseData.errorCode === "0" &&
116
+ responseData.content &&
117
+ responseData.content.skills) {
118
+ const rawSkills = responseData.content.skills;
119
+ const installedSkills = getInstalledSkills();
120
+ const formattedData = formatSkillData(rawSkills, installedSkills);
121
+ const topTools = formattedData.slice(0, 2);
122
+ const allInstalled = topTools.every((tool) => tool.status === "已安装");
123
+ if (allInstalled) {
124
+ console.log(`${PLUGIN_LOG_PREFIX} [DEBUG] All top 2 skills are installed, returning null`);
125
+ return null;
126
+ }
127
+ let filteredTools = topTools.filter((tool) => tool.status === "未安装");
128
+ console.log(`${PLUGIN_LOG_PREFIX} [DEBUG] After filtering uninstalled: ${filteredTools.length}, ids: ${filteredTools.map((t) => t.skillId).join(", ")}`);
129
+ return {
130
+ tools: filteredTools,
131
+ query,
132
+ timestamp: Date.now(),
133
+ };
134
+ }
135
+ console.warn(`${PLUGIN_LOG_PREFIX} Invalid response format: ${JSON.stringify(responseData).slice(0, 200)}`);
136
+ return null;
137
+ }
138
+ catch (error) {
139
+ const errorName = error instanceof Error ? error.name : "Unknown";
140
+ const errorMessage = error instanceof Error ? error.message : String(error);
141
+ const errorCause = error instanceof Error && error.cause ? JSON.stringify(error.cause) : "N/A";
142
+ const errorStack = error instanceof Error ? error.stack?.split("\n").slice(0, 3).join(" | ") : "N/A";
143
+ console.warn(`${PLUGIN_LOG_PREFIX} [ERROR] Fetch failed - name: ${errorName}, message: ${errorMessage}, cause: ${errorCause}, stack: ${errorStack}`);
144
+ return null;
145
+ }
146
+ }
147
+ export function formatToolsForContext(result, includeInstallUrl = true) {
148
+ if (!result.tools || result.tools.length === 0) {
149
+ return "";
150
+ }
151
+ const toolDescriptions = [];
152
+ for (const tool of result.tools) {
153
+ let description = `### ${tool.skillName}\n`;
154
+ description += `name: ${tool.skillId}\n`;
155
+ description += `description: ${tool.skillDesc}\n`;
156
+ toolDescriptions.push(description);
157
+ }
158
+ return toolDescriptions.join("\n\n");
159
+ }
@@ -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
+ };
@@ -400,6 +400,12 @@ export class XYWebSocketManager extends EventEmitter {
400
400
  event: item,
401
401
  });
402
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
+ }
403
409
  }
404
410
  }
405
411
  return;
@@ -454,6 +460,12 @@ export class XYWebSocketManager extends EventEmitter {
454
460
  taskId: inboundMsg.taskId || a2aRequest.params?.id,
455
461
  });
456
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
+ }
457
469
  }
458
470
  }
459
471
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ynhcj/xiaoyi-channel",
3
- "version": "0.0.97-beta",
3
+ "version": "0.0.99-beta",
4
4
  "description": "OpenClaw Xiaoyi Channel plugin - Xiaoyi A2A protocol integration",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",