@ynhcj/xiaoyi-channel 0.0.212-beta → 0.0.213-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
@@ -11,6 +11,43 @@ import { registerSelfEvolutionToolResultNudge } from "./src/self-evolution-tool-
11
11
  import { createBeforePromptBuildHandler } from "./src/skill-retriever/hooks.js";
12
12
  import { normalizeToolRetrieverConfig } from "./src/skill-retriever/config.js";
13
13
  import { registerCLIHook } from "./src/tools/hmos-cli.js";
14
+ import { writeSkillUsage } from "./src/utils/skills-logger.js";
15
+ /**
16
+ * Parse a file path string to detect if it refers to a SKILL.md file within
17
+ * a skills directory. Returns the skill name (parent directory) if so.
18
+ *
19
+ * Matches paths like:
20
+ * ~/.openclaw/workspace/skills/my-skill/SKILL.md
21
+ * /home/user/core_skills/my-skill/SKILL.md
22
+ * skills/my-skill/SKILL.md
23
+ */
24
+ function extractSkillNameFromPath(filePath) {
25
+ if (typeof filePath !== "string" || !filePath)
26
+ return null;
27
+ // Normalize common path prefixes
28
+ const normalized = filePath.replace(/^~\//, "/home/").replace(/\\/g, "/");
29
+ // Match: .../skills/<skillName>/SKILL.md or .../skills/<skillName>/...
30
+ // Also match: .../core_skills/<skillName>/SKILL.md
31
+ const match = normalized.match(/\/(?:core_)?skills\/([^/]+)\/SKILL\.md$/i);
32
+ return match ? match[1] : null;
33
+ }
34
+ /**
35
+ * Register the skills diagnostic event listener via after_tool_call hook.
36
+ *
37
+ * When openclaw fires a `skill.used` diagnostic event, the skill's SKILL.md
38
+ * is typically read by the model first. We detect SKILL.md reads through
39
+ * the `after_tool_call` hook and write the skill name to the skills log.
40
+ */
41
+ function registerSkillsDiagnosticHook(api) {
42
+ api.on("after_tool_call", async (event, _ctx) => {
43
+ if (event.toolName !== "read")
44
+ return;
45
+ const skillName = extractSkillNameFromPath(event.params?.path);
46
+ if (skillName) {
47
+ writeSkillUsage(skillName);
48
+ }
49
+ });
50
+ }
14
51
  /**
15
52
  * Register the cron detection hook.
16
53
  *
@@ -211,6 +248,8 @@ const pluginEntry = definePluginEntry({
211
248
  registerCronDetectionHook(api);
212
249
  // CLI exec hook: intercepts built-in exec for HarmonyOS CLI skill tools
213
250
  registerCLIHook(api);
251
+ // Skills diagnostic hook: log skill usage (detected via SKILL.md reads)
252
+ registerSkillsDiagnosticHook(api);
214
253
  }
215
254
  },
216
255
  });
package/dist/src/bot.d.ts CHANGED
@@ -24,8 +24,3 @@ export interface HandleXYMessageParams {
24
24
  * Runtime is expected to be validated before calling this function.
25
25
  */
26
26
  export declare function handleXYMessage(params: HandleXYMessageParams): Promise<void>;
27
- /**
28
- * 由 provider.ts 在 wrapStreamFn 调用时触发。
29
- * 这是模型 API 被调用的精确时刻,此时 isStreaming 一定为 true。
30
- */
31
- export declare function notifyModelStreaming(sessionId: string): void;
package/dist/src/bot.js CHANGED
@@ -310,9 +310,6 @@ export async function handleXYMessage(params) {
310
310
  return;
311
311
  }
312
312
  // ── First message (non-steer) path below ──────────────────────
313
- // 🔑 立即创建 streaming 信号——必须在文件下载等耗时操作之前,
314
- // 否则 steer 消息的 dispatchSteerWhenReady 会找不到信号而跳过等待。
315
- createStreamingSignal(parsed.sessionId);
316
313
  // File download — only for real user messages, steer injections have no files
317
314
  let mediaPayload = {};
318
315
  if (!skipReg) {
@@ -372,7 +369,6 @@ export async function handleXYMessage(params) {
372
369
  return;
373
370
  cleaned = true;
374
371
  log.log(`[BOT] Cleanup started`);
375
- streamingSignals.delete(parsed.sessionId);
376
372
  decrementTaskIdRef(parsed.sessionId);
377
373
  log.log(`[BOT] Cleanup completed`);
378
374
  };
@@ -495,45 +491,22 @@ function buildXYMediaPayload(mediaList) {
495
491
  MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
496
492
  };
497
493
  }
494
+ // ─────────────────────────────────────────────────────────────
495
+ // Steer 串行队列
496
+ // ─────────────────────────────────────────────────────────────
498
497
  // Use globalThis to survive module deduplication — provider.ts may load a
499
498
  // different copy of bot.ts, so a plain module-level Map would be two objects.
500
499
  const _g = globalThis;
501
- if (!_g.__xyStreamingSignals)
502
- _g.__xyStreamingSignals = new Map();
503
500
  if (!_g.__xySteerQueues)
504
501
  _g.__xySteerQueues = new Map();
505
502
  if (!_g.__xyDispatcherUpdaters)
506
503
  _g.__xyDispatcherUpdaters = new Map();
507
- const streamingSignals = _g.__xyStreamingSignals;
508
504
  const steerQueues = _g.__xySteerQueues;
509
505
  const dispatcherUpdaters = _g.__xyDispatcherUpdaters;
510
- /**
511
- * 由 provider.ts 在 wrapStreamFn 调用时触发。
512
- * 这是模型 API 被调用的精确时刻,此时 isStreaming 一定为 true。
513
- */
514
- export function notifyModelStreaming(sessionId) {
515
- const log = logger.withContext(sessionId, "");
516
- const signal = streamingSignals.get(sessionId);
517
- if (signal) {
518
- // 不删除 signal——后续 steer 需要靠它判断模型已在 streaming。
519
- // 清理由第一条消息的 onSettled 兜底。
520
- signal.notify();
521
- log.log(`[STEER-QUEUE] Model streaming signal fired`);
522
- }
523
- }
524
- function createStreamingSignal(sessionId) {
525
- const log = logger.withContext(sessionId, "");
526
- let resolve;
527
- const promise = new Promise(r => { resolve = r; });
528
- const signal = { promise, notify: resolve };
529
- streamingSignals.set(sessionId, signal);
530
- log.log(`[STEER-QUEUE] Streaming signal created`);
531
- return signal;
532
- }
533
506
  /**
534
507
  * 将 steer 消息放入 per-session 串行队列。
535
- * 等待第一条消息的 streaming 信号(deliver 首次触发),然后 dispatch。
536
- * 多个 steer 按到达顺序串行处理,无需重试。
508
+ * 通过指数退避轮询注入(queueAgentHarnessMessage),直到 Pi agent 接受消息或 run 结束。
509
+ * 多个 steer 按到达顺序串行处理。
537
510
  */
538
511
  function enqueueSteer(params) {
539
512
  const { sessionId } = params;
@@ -552,69 +525,52 @@ function enqueueSteer(params) {
552
525
  });
553
526
  return next;
554
527
  }
528
+ /**
529
+ * Poll the active embedded run with exponential backoff until the steer message
530
+ * is accepted or the run completes.
531
+ *
532
+ * Replaces the previous signal-based approach (notifyModelStreaming) which fired
533
+ * from wrapStreamFn too early — before the Pi agent had set state.isStreaming=true.
534
+ * queueAgentHarnessMessage internally checks isStreaming(), so we retry until the
535
+ * Pi agent is ready to accept messages.
536
+ */
555
537
  async function dispatchSteerWhenReady(params) {
556
538
  const { sessionId, steerText } = params;
557
539
  const log = logger.withContext(sessionId, params.parsed.taskId);
558
- // 1. 等待第一条消息开始 streaming
559
- // signal 可能尚未创建(第一条消息还在文件下载等耗时操作中),
560
- // 轮询等待直到 signal 出现,最長等待 ~5 秒。
561
- let signal = streamingSignals.get(sessionId);
562
- if (!signal) {
563
- log.log(`[STEER-QUEUE] Signal not yet created, polling`);
564
- for (let i = 0; i < 50; i++) {
565
- await new Promise(r => setTimeout(r, 100));
566
- signal = streamingSignals.get(sessionId);
567
- if (signal)
568
- break;
569
- if (!hasActiveTask(sessionId)) {
570
- log.log(`[STEER-QUEUE] First message completed while waiting, skip steer`);
571
- return;
572
- }
573
- }
574
- }
575
- if (signal) {
576
- log.log(`[STEER-QUEUE] Waiting for streaming signal`);
577
- await signal.promise;
578
- log.log(`[STEER-QUEUE] Streaming signal received`);
579
- }
580
- else {
581
- // 轮询超时且 hasActiveTask 仍为 true——说明第一条消息可能卡在异常路径,
582
- // 没有创建 signal。此时放弃,避免并发碰撞。
583
- log.log(`[STEER-QUEUE] Signal never appeared after polling, skip steer to avoid collision`);
584
- return;
585
- }
586
- // 2. 第一条消息已结束 → 放弃
587
- if (!hasActiveTask(sessionId)) {
588
- log.log(`[STEER-QUEUE] First message completed, skip steer`);
589
- return;
590
- }
591
- // 3. 直接注入到活跃的 Pi run 中,不创建独立 dispatcher。
592
- // 模型回复通过第一条消息的 dispatcher 的 onPartialReply 流式发出,
593
- // 使用 registerTaskId + updateFallbackTaskId 已同步的最新 taskId。
594
540
  const mediaPaths = params.mediaPayload?.MediaPaths;
595
541
  const fileHint = mediaPaths && mediaPaths.length > 0
596
542
  ? `\n【用户上传附件】:${JSON.stringify(mediaPaths)}`
597
543
  : "";
598
544
  const steerMessage = `${steerText}${fileHint}`;
599
545
  log.log(`[STEER-QUEUE] Injecting steer message directly into active run`);
600
- const STEER_RETRY_MAX = 3;
601
- const STEER_RETRY_DELAY_MS = 500;
602
- let injected = false;
603
- for (let attempt = 0; attempt < STEER_RETRY_MAX; attempt++) {
604
- injected = queueAgentHarnessMessage(sessionId, steerMessage, {
546
+ // Exponential backoff polling until:
547
+ // 1. Injection succeeds (queueAgentHarnessMessage returns true)
548
+ // 2. The original run has completed (hasActiveTask returns false)
549
+ // 3. Maximum total wait time exceeded
550
+ const MAX_WAIT_MS = 30_000;
551
+ const MAX_BACKOFF_MS = 4_000;
552
+ const BASE_DELAY_MS = 500;
553
+ const startedAt = Date.now();
554
+ let attempt = 0;
555
+ while (Date.now() - startedAt < MAX_WAIT_MS) {
556
+ // Termination: first message's run has already finished
557
+ if (!hasActiveTask(sessionId)) {
558
+ log.log(`[STEER-QUEUE] First message completed, skip steer`);
559
+ return;
560
+ }
561
+ const injected = queueAgentHarnessMessage(sessionId, steerMessage, {
605
562
  steeringMode: "all",
606
563
  });
607
- if (injected)
608
- break;
609
- if (attempt < STEER_RETRY_MAX - 1 && hasActiveTask(sessionId)) {
610
- log.log(`[STEER-QUEUE] Retry ${attempt + 1}/${STEER_RETRY_MAX}: run not accepting, waiting ${STEER_RETRY_DELAY_MS}ms`);
611
- await new Promise(r => setTimeout(r, STEER_RETRY_DELAY_MS));
564
+ if (injected) {
565
+ log.log(`[STEER-QUEUE] Steer injected successfully on attempt ${attempt}`);
566
+ return;
612
567
  }
568
+ // Exponential backoff: min(BASE_DELAY_MS * 2^attempt, MAX_BACKOFF_MS)
569
+ const delay = Math.min(BASE_DELAY_MS * Math.pow(2, attempt), MAX_BACKOFF_MS);
570
+ log.log(`[STEER-QUEUE] Attempt ${attempt}: run not accepting, retrying in ${delay}ms`);
571
+ await new Promise(r => setTimeout(r, delay));
572
+ attempt++;
613
573
  }
614
- if (injected) {
615
- log.log(`[STEER-QUEUE] Steer message injected successfully`);
616
- }
617
- else {
618
- log.log(`[STEER-QUEUE] Steer injection failed after ${STEER_RETRY_MAX} attempts — run may not be accepting messages`);
619
- }
574
+ // Max wait exceeded — model may still be in provider retry loop or unresponsive
575
+ log.log(`[STEER-QUEUE] Steer injection failed after ${MAX_WAIT_MS}ms timeout`);
620
576
  }
@@ -32,6 +32,11 @@ const MONITORS = [
32
32
  businessType: "openclaw-init",
33
33
  jsonParse: false,
34
34
  },
35
+ {
36
+ path: "/tmp/openclaw/skills-{year}{month}{day}.log",
37
+ businessType: "openclaw-skill",
38
+ jsonParse: false,
39
+ },
35
40
  ];
36
41
  // ── State ────────────────────────────────────────────────────────────────────
37
42
  let intervalId = null;
@@ -11,7 +11,6 @@ import { createHash } from "crypto";
11
11
  import { logger } from "./utils/logger.js";
12
12
  import { getCurrentSessionContext, setCurrentCronJobId } from "./tools/session-manager.js";
13
13
  import { selfEvolutionManager } from "./utils/self-evolution-manager.js";
14
- import { notifyModelStreaming } from "./bot.js";
15
14
  // ── Retry config ──────────────────────────────────────────────
16
15
  const RETRY_DELAYS_MS = [10_000, 20_000, 40_000, 60_000, 60_000];
17
16
  const MAX_RETRY_ATTEMPTS = 5;
@@ -588,17 +587,13 @@ export const xiaoyiProvider = {
588
587
  }
589
588
  // 记录输入
590
589
  logger.log(`[xiaoyiprovider] input messages count: ${context.messages?.length ?? 0}`);
591
- // 🔑 通知 steer 队列:模型 API 已被调用,此时 isStreaming 一定为 true
592
- const sessionCtx = getCurrentSessionContext();
593
- if (sessionCtx?.sessionId) {
594
- notifyModelStreaming(sessionCtx.sessionId);
595
- }
596
590
  if (context.systemPrompt) {
597
591
  logger.log(`[xiaoyiprovider] system prompt length: ${context.systemPrompt.length}`);
598
592
  }
599
593
  // deviceType: prefer text-extracted value, ALS as fallback.
594
+ const sessionCtx = getCurrentSessionContext();
600
595
  const deviceType = extractedDeviceType
601
- ?? getCurrentSessionContext()?.deviceType;
596
+ ?? sessionCtx?.deviceType;
602
597
  // app_ver and sdk_api_version from session context (ALS)
603
598
  const appVer = sessionCtx?.appVer;
604
599
  const sdkApiVersion = sessionCtx?.sdkApiVersion;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Write a skill-usage record to the skills log.
3
+ * Format: |YYYYMMDDThhmmss|skillName|
4
+ */
5
+ export declare function writeSkillUsage(skillName: string): void;
@@ -0,0 +1,76 @@
1
+ // Skills usage logger
2
+ // Format: |timestamp|skillName|
3
+ import { mkdirSync, readdirSync, unlinkSync, statSync } from "fs";
4
+ import { join } from "path";
5
+ import pino from "pino";
6
+ // ── Configuration ──
7
+ const LOG_DIR = "/tmp/openclaw";
8
+ const LOG_PREFIX = "skills";
9
+ const MAX_AGE_DAYS = 30;
10
+ // ── UTC+8 helpers ──
11
+ function getTodayDateStr() {
12
+ const utc8 = new Date(Date.now() + 8 * 60 * 60 * 1000);
13
+ return `${utc8.getUTCFullYear()}${String(utc8.getUTCMonth() + 1).padStart(2, "0")}${String(utc8.getUTCDate()).padStart(2, "0")}`;
14
+ }
15
+ function formatTimestampUTC8() {
16
+ const utc8 = new Date(Date.now() + 8 * 60 * 60 * 1000);
17
+ const y = utc8.getUTCFullYear();
18
+ const M = String(utc8.getUTCMonth() + 1).padStart(2, "0");
19
+ const D = String(utc8.getUTCDate()).padStart(2, "0");
20
+ const h = String(utc8.getUTCHours()).padStart(2, "0");
21
+ const m = String(utc8.getUTCMinutes()).padStart(2, "0");
22
+ const s = String(utc8.getUTCSeconds()).padStart(2, "0");
23
+ return `${y}${M}${D}T${h}${m}${s}`;
24
+ }
25
+ function getLogFilePath(dateStr) {
26
+ return join(LOG_DIR, `${LOG_PREFIX}-${dateStr ?? getTodayDateStr()}.log`);
27
+ }
28
+ // ── Ensure log directory ──
29
+ try {
30
+ mkdirSync(LOG_DIR, { recursive: true });
31
+ }
32
+ catch { }
33
+ // ── Cleanup expired logs (older than MAX_AGE_DAYS) ──
34
+ function cleanupOldLogs() {
35
+ try {
36
+ const files = readdirSync(LOG_DIR);
37
+ const cutoff = Date.now() - MAX_AGE_DAYS * 24 * 60 * 60 * 1000;
38
+ for (const f of files) {
39
+ if (!f.startsWith(LOG_PREFIX) || !f.endsWith(".log"))
40
+ continue;
41
+ const fp = join(LOG_DIR, f);
42
+ try {
43
+ if (statSync(fp).mtimeMs < cutoff)
44
+ unlinkSync(fp);
45
+ }
46
+ catch { }
47
+ }
48
+ }
49
+ catch { }
50
+ }
51
+ // Run cleanup on module load
52
+ cleanupOldLogs();
53
+ // Schedule periodic cleanup every 6 hours
54
+ const cleanupTimer = setInterval(cleanupOldLogs, 6 * 60 * 60 * 1000);
55
+ cleanupTimer.unref?.();
56
+ // ── File destination with daily rotation ──
57
+ let currentDate = getTodayDateStr();
58
+ const dest = pino.destination({ dest: getLogFilePath(currentDate), sync: false, mkdir: true });
59
+ // ── Rotation check ──
60
+ function checkRotation() {
61
+ const today = getTodayDateStr();
62
+ if (today !== currentDate) {
63
+ currentDate = today;
64
+ dest.reopen(getLogFilePath(today));
65
+ cleanupOldLogs();
66
+ }
67
+ }
68
+ /**
69
+ * Write a skill-usage record to the skills log.
70
+ * Format: |YYYYMMDDThhmmss|skillName|
71
+ */
72
+ export function writeSkillUsage(skillName) {
73
+ checkRotation();
74
+ const timestamp = formatTimestampUTC8();
75
+ dest.write(`|${timestamp}|${skillName}|\n`);
76
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ynhcj/xiaoyi-channel",
3
- "version": "0.0.212-beta",
3
+ "version": "0.0.213-beta",
4
4
  "description": "OpenClaw Xiaoyi Channel plugin - Xiaoyi A2A protocol integration",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",