@ynhcj/xiaoyi-channel 0.0.138-beta → 0.0.138-next

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/src/bot.d.ts CHANGED
@@ -24,3 +24,8 @@ 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
@@ -197,11 +197,40 @@ export async function handleXYMessage(params) {
197
197
  logger.error(`[SELF_EVOLUTION] Failed to append inline keyword nudge: ${String(selfEvolutionError)}`);
198
198
  }
199
199
  }
200
- // 🔑 Steer消息加 /steer 前缀,触发core的 queueEmbeddedPiMessage
201
- if (isUpdate && textForAgent) {
202
- textForAgent = `/steer ${textForAgent}`;
203
- logger.log(`[BOT] 🔄 Prepended /steer for steer injection`);
200
+ // 🔑 Steer消息: 跳过旧路径直接进入 streaming-signal 队列
201
+ // /steer 前缀由 dispatchSteerWhenReady 内部添加
202
+ if (isUpdate) {
203
+ // 立即释放 init gate——steer 不走 withReplyDispatcher 的 run()
204
+ // 回调,onInitComplete 永远不会被触发。如果不释放,后续消息
205
+ // 会被 globalDispatchInitGate 永久阻塞。
206
+ params.onInitComplete?.();
207
+ // Steer 也支持文件 —— 提取并下载,附带到 mediaPayload
208
+ const steerFileParts = extractFileParts(parsed.parts);
209
+ const steerDownloadedFiles = await downloadFilesFromParts(steerFileParts);
210
+ const steerMediaPayload = buildXYMediaPayload(steerDownloadedFiles);
211
+ if (steerFileParts.length > 0) {
212
+ logger.log(`[BOT] 📎 Steer message with files: ${steerFileParts.length} file(s)`);
213
+ }
214
+ logger.log(`[BOT] 🔄 Steer message — enqueuing to streaming-signal queue`);
215
+ await enqueueSteer({
216
+ sessionId: parsed.sessionId,
217
+ sessionKey: route.sessionKey,
218
+ steerText: textForAgent, // 原始文本,不带 /steer 前缀
219
+ mediaPayload: steerMediaPayload,
220
+ cfg,
221
+ runtime,
222
+ parsed,
223
+ route,
224
+ deviceType,
225
+ });
226
+ logger.log(`[BOT] ✅ Steer queue completed for session: ${parsed.sessionId}`);
227
+ logger.log(`xy: dispatch complete (session=${parsed.sessionId})`);
228
+ return;
204
229
  }
230
+ // ── First message (non-steer) path below ──────────────────────
231
+ // 🔑 立即创建 streaming 信号——必须在文件下载等耗时操作之前,
232
+ // 否则 steer 消息的 dispatchSteerWhenReady 会找不到信号而跳过等待。
233
+ createStreamingSignal(parsed.sessionId);
205
234
  // File download — only for real user messages, steer injections have no files
206
235
  let mediaPayload = {};
207
236
  if (!skipReg) {
@@ -241,7 +270,7 @@ export async function handleXYMessage(params) {
241
270
  SenderId: parsed.sessionId,
242
271
  Provider: "xiaoyi-channel",
243
272
  Surface: "xiaoyi-channel",
244
- MessageSid: `${parsed.taskId}_${deviceType}`,
273
+ MessageSid: `xiaoyi_${parsed.taskId}_${deviceType}`,
245
274
  Timestamp: Date.now(),
246
275
  WasMentioned: false,
247
276
  CommandAuthorized: true,
@@ -250,10 +279,8 @@ export async function handleXYMessage(params) {
250
279
  ReplyToBody: undefined, // A2A protocol doesn't support reply/quote
251
280
  ...mediaPayload,
252
281
  });
253
- // 🔑 Dynamic steer state: when isUpdate (second message), start as steered=true
254
- // so the dispatcher skips all user-facing callbacks (deliver, onIdle, etc.)
255
- // and onSettled skips cleanup.
256
- const steerState = { steered: isUpdate };
282
+ // 🔑 Streaming 信号已在上方创建(在文件下载之前)
283
+ const steerState = { steered: false };
257
284
  // 🔑 创建dispatcher
258
285
  logger.log(`[BOT-DISPATCHER] 🎯 Creating reply dispatcher`);
259
286
  logger.log(`[BOT-DISPATCHER] - taskId: ${parsed.taskId}`);
@@ -290,6 +317,7 @@ export async function handleXYMessage(params) {
290
317
  logger.log(`[BOT] ✅ Steered dispatch settled (skipping cleanup)`);
291
318
  return;
292
319
  }
320
+ streamingSignals.delete(parsed.sessionId);
293
321
  decrementTaskIdRef(parsed.sessionId);
294
322
  unregisterSession(route.sessionKey);
295
323
  logger.log(`[BOT] ✅ Cleanup completed`);
@@ -386,3 +414,169 @@ function buildXYMediaPayload(mediaList) {
386
414
  MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
387
415
  };
388
416
  }
417
+ // Use globalThis to survive module deduplication — provider.ts may load a
418
+ // different copy of bot.ts, so a plain module-level Map would be two objects.
419
+ const _g = globalThis;
420
+ if (!_g.__xyStreamingSignals)
421
+ _g.__xyStreamingSignals = new Map();
422
+ if (!_g.__xySteerQueues)
423
+ _g.__xySteerQueues = new Map();
424
+ const streamingSignals = _g.__xyStreamingSignals;
425
+ const steerQueues = _g.__xySteerQueues;
426
+ /**
427
+ * 由 provider.ts 在 wrapStreamFn 调用时触发。
428
+ * 这是模型 API 被调用的精确时刻,此时 isStreaming 一定为 true。
429
+ */
430
+ export function notifyModelStreaming(sessionId) {
431
+ const signal = streamingSignals.get(sessionId);
432
+ if (signal) {
433
+ // 不删除 signal——后续 steer 需要靠它判断模型已在 streaming。
434
+ // 清理由第一条消息的 onSettled 兜底。
435
+ signal.notify();
436
+ logger.log(`[STEER-QUEUE] 📡 Model streaming signal fired for session=${sessionId}`);
437
+ }
438
+ }
439
+ function createStreamingSignal(sessionId) {
440
+ let resolve;
441
+ const promise = new Promise(r => { resolve = r; });
442
+ const signal = { promise, notify: resolve };
443
+ streamingSignals.set(sessionId, signal);
444
+ logger.log(`[STEER-QUEUE] 🟢 Streaming signal created for session ${sessionId}`);
445
+ return signal;
446
+ }
447
+ /**
448
+ * 将 steer 消息放入 per-session 串行队列。
449
+ * 等待第一条消息的 streaming 信号(deliver 首次触发),然后 dispatch。
450
+ * 多个 steer 按到达顺序串行处理,无需重试。
451
+ */
452
+ function enqueueSteer(params) {
453
+ const { sessionId } = params;
454
+ // 取出当前队列尾部(或 undefined),然后链上新的 Promise
455
+ const prev = steerQueues.get(sessionId);
456
+ const next = (prev ?? Promise.resolve()).then(() => dispatchSteerWhenReady(params));
457
+ steerQueues.set(sessionId, next);
458
+ // 链条结束后清理
459
+ next.catch((err) => {
460
+ logger.error(`[STEER-QUEUE] ❌ Steer chain failed: ${String(err)}`);
461
+ }).finally(() => {
462
+ if (steerQueues.get(sessionId) === next) {
463
+ steerQueues.delete(sessionId);
464
+ }
465
+ });
466
+ return next;
467
+ }
468
+ async function dispatchSteerWhenReady(params) {
469
+ const { sessionId, sessionKey, steerText } = params;
470
+ // 1. 等待第一条消息开始 streaming
471
+ // signal 可能尚未创建(第一条消息还在文件下载等耗时操作中),
472
+ // 轮询等待直到 signal 出现,最長等待 ~5 秒。
473
+ let signal = streamingSignals.get(sessionId);
474
+ if (!signal) {
475
+ logger.log(`[STEER-QUEUE] ⏳ Signal not yet created, polling for session=${sessionId}`);
476
+ for (let i = 0; i < 50; i++) {
477
+ await new Promise(r => setTimeout(r, 100));
478
+ signal = streamingSignals.get(sessionId);
479
+ if (signal)
480
+ break;
481
+ if (!hasActiveTask(sessionId)) {
482
+ logger.log(`[STEER-QUEUE] ℹ️ First message completed while waiting, skip steer`);
483
+ return;
484
+ }
485
+ }
486
+ }
487
+ if (signal) {
488
+ logger.log(`[STEER-QUEUE] ⏳ Waiting for streaming signal, session=${sessionId}`);
489
+ await signal.promise;
490
+ logger.log(`[STEER-QUEUE] ✅ Streaming signal received, session=${sessionId}`);
491
+ }
492
+ else {
493
+ // 轮询超时且 hasActiveTask 仍为 true——说明第一条消息可能卡在异常路径,
494
+ // 没有创建 signal。此时 dispatch 会与第一条消息的模型调用并发冲突,放弃。
495
+ logger.log(`[STEER-QUEUE] ⚠️ Signal never appeared after polling, skip steer to avoid collision`);
496
+ return;
497
+ }
498
+ // 2. 第一条消息已结束 → 放弃
499
+ if (!hasActiveTask(sessionId)) {
500
+ logger.log(`[STEER-QUEUE] ℹ️ First message completed, skip steer`);
501
+ return;
502
+ }
503
+ // 3. 构建 dispatch 上下文并 dispatch /steer
504
+ const core = getXYRuntime();
505
+ const speaker = sessionId;
506
+ // 如果有文件附件,把路径拼到 steer 文本末尾,让模型通过工具读取
507
+ const mediaPaths = params.mediaPayload?.MediaPaths;
508
+ const fileHint = mediaPaths && mediaPaths.length > 0
509
+ ? `\n【用户上传附件】:${JSON.stringify(mediaPaths)}`
510
+ : "";
511
+ const steerCommand = `/steer ${steerText}${fileHint}`;
512
+ const messageBody = `${speaker}: ${steerCommand}`;
513
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(params.cfg);
514
+ const body = core.channel.reply.formatAgentEnvelope({
515
+ channel: "xiaoyi-channel",
516
+ from: speaker,
517
+ timestamp: new Date(),
518
+ envelope: envelopeOptions,
519
+ body: messageBody,
520
+ });
521
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
522
+ Body: body,
523
+ RawBody: steerCommand,
524
+ CommandBody: steerCommand,
525
+ From: sessionId,
526
+ To: sessionId,
527
+ SessionKey: params.route.sessionKey,
528
+ AccountId: params.route.accountId,
529
+ ChatType: "direct",
530
+ GroupSubject: undefined,
531
+ SenderName: sessionId,
532
+ SenderId: sessionId,
533
+ Provider: "xiaoyi-channel",
534
+ Surface: "xiaoyi-channel",
535
+ MessageSid: `xiaoyi_${params.parsed.taskId}_${params.deviceType}`,
536
+ Timestamp: Date.now(),
537
+ WasMentioned: false,
538
+ CommandAuthorized: true,
539
+ OriginatingChannel: "xiaoyi-channel",
540
+ OriginatingTo: sessionId,
541
+ ReplyToBody: undefined,
542
+ ...params.mediaPayload,
543
+ });
544
+ const steerState = { steered: true };
545
+ const { dispatcher, replyOptions } = createXYReplyDispatcher({
546
+ cfg: params.cfg,
547
+ runtime: params.runtime,
548
+ sessionId,
549
+ taskId: params.parsed.taskId,
550
+ messageId: params.parsed.messageId,
551
+ accountId: params.route.accountId,
552
+ steerState,
553
+ });
554
+ const sessionContext = {
555
+ config: resolveXYConfig(params.cfg),
556
+ sessionId,
557
+ taskId: params.parsed.taskId,
558
+ messageId: params.parsed.messageId,
559
+ agentId: params.route.accountId,
560
+ deviceType: params.deviceType,
561
+ };
562
+ logger.log(`[STEER-QUEUE] 🚀 Dispatching steer for session=${sessionId}`);
563
+ await core.channel.reply.withReplyDispatcher({
564
+ dispatcher,
565
+ onSettled: () => {
566
+ logger.log(`[STEER-QUEUE] 🏁 Steer dispatch settled for session=${sessionId}`);
567
+ },
568
+ run: () => {
569
+ return runWithSessionContext(sessionContext, async () => {
570
+ const result = await core.channel.reply.dispatchReplyFromConfig({
571
+ ctx: ctxPayload,
572
+ cfg: params.cfg,
573
+ dispatcher,
574
+ replyOptions,
575
+ });
576
+ logger.log(`[STEER-QUEUE] dispatch result: ${JSON.stringify(result)}`);
577
+ return result;
578
+ });
579
+ },
580
+ });
581
+ logger.log(`[STEER-QUEUE] ✅ Steer dispatch completed for session=${sessionId}`);
582
+ }
@@ -11,6 +11,7 @@ import { createHash } from "crypto";
11
11
  import { logger } from "./utils/logger.js";
12
12
  import { getCurrentSessionContext } from "./tools/session-manager.js";
13
13
  import { selfEvolutionManager } from "./utils/self-evolution-manager.js";
14
+ import { notifyModelStreaming } from "./bot.js";
14
15
  // ── Retry config ──────────────────────────────────────────────
15
16
  const RETRY_DELAYS_MS = [10_000, 20_000, 40_000, 60_000, 60_000];
16
17
  const MAX_RETRY_ATTEMPTS = 5;
@@ -45,6 +46,11 @@ function getFirstUserText(messages) {
45
46
  }
46
47
  /** Regex to match `[cron:<uuid> <title>]` anywhere in text. */
47
48
  const CRON_TAG_RE = /\[cron:[^\s\]]+\s+([^\]]+)\]/;
49
+ /** Extract the cron job UUID from the first user message, e.g. `[cron:abc123 ...]` → `abc123`. */
50
+ function extractCronUuid(messages) {
51
+ const match = getFirstUserText(messages).match(/\[cron:([^\s\]]+)/i);
52
+ return match ? match[1] : undefined;
53
+ }
48
54
  /** Check if the request is triggered by a cron job by inspecting the first user message. */
49
55
  function isCronTriggered(messages) {
50
56
  return /\[cron:/i.test(getFirstUserText(messages));
@@ -230,8 +236,6 @@ const HEADER_SESSION_ID = "x-session-id";
230
236
  const HEADER_INTERACTION_ID = "x-interaction-id";
231
237
  /** Internal key for passing fallback uid prefix from prepareExtraParams to wrapStreamFn. */
232
238
  const FALLBACK_PREFIX_KEY = "_xiaoyi_fallback_prefix";
233
- /** Internal key for passing deviceType from prepareExtraParams to wrapStreamFn. */
234
- const DEVICE_TYPE_KEY = "_xiaoyi_device_type";
235
239
  const SELF_EVOLUTION_PROMPT_BEGIN = "<self_evolution_prompt>";
236
240
  const SELF_EVOLUTION_PROMPT_END = "</self_evolution_prompt>";
237
241
  const SELF_EVOLUTION_ENABLED_PROMPT_SECTION = `
@@ -391,7 +395,9 @@ function trimUserMetadata(text) {
391
395
  }
392
396
  /**
393
397
  * Extract A2A taskId and deviceType from Conversation info JSON.
394
- * bot.ts stores them as MessageSid = "taskId_deviceType".
398
+ * bot.ts stores them as MessageSid = "xiaoyi_taskId_deviceType".
399
+ * The "xiaoyi_" prefix ensures extraction only happens for messages
400
+ * routed through xiaoyi-channel, not other channels sharing the provider.
395
401
  */
396
402
  function extractA2AFromConversationInfo(text) {
397
403
  const match = text.match(/Conversation info \(untrusted metadata\):\n```json\n([\s\S]*?)\n```/);
@@ -401,9 +407,9 @@ function extractA2AFromConversationInfo(text) {
401
407
  if (!msgIdMatch)
402
408
  return null;
403
409
  const parts = msgIdMatch[1].split("_");
404
- if (parts.length < 2)
410
+ if (parts.length < 3 || parts[0] !== "xiaoyi")
405
411
  return null;
406
- return { taskId: parts[0], deviceType: parts[1] };
412
+ return { taskId: parts[1], deviceType: parts[2] };
407
413
  }
408
414
  export const xiaoyiProvider = {
409
415
  id: "xiaoyiprovider",
@@ -412,31 +418,12 @@ export const xiaoyiProvider = {
412
418
  auth: [],
413
419
  isCacheTtlEligible: () => true,
414
420
  /**
415
- * Inject dynamic session params into extraParams so they flow
416
- * through to wrapStreamFn's ctx.extraParams.
417
- *
418
- * Priority:
419
- * 1. Session context (from AsyncLocalStorage, set by bot.ts)
420
- * 2. uid-based fallback: sha256(uid).hex[:32]_timestamp
421
- * 3. No uid available → return undefined (no headers injected)
421
+ * Store uid-based fallback prefix for lazy timestamp generation in wrapStreamFn.
422
+ * Session-level headers (traceId / sessionId / interactionId) are resolved
423
+ * directly in wrapStreamFn via cron detection, Conversation info extraction,
424
+ * or uid fallback.
422
425
  */
423
426
  prepareExtraParams: (ctx) => {
424
- const sessionCtx = getCurrentSessionContext();
425
- if (sessionCtx) {
426
- const taskId = sessionCtx.taskId;
427
- const sessionId = taskId.split("&")[0];
428
- const interactionId = taskId.split("&")[1] || "";
429
- return {
430
- ...ctx.extraParams,
431
- [HEADER_TRACE_ID]: taskId,
432
- [HEADER_SESSION_ID]: sessionId,
433
- [HEADER_INTERACTION_ID]: interactionId,
434
- [DEVICE_TYPE_KEY]: sessionCtx.deviceType ?? "",
435
- };
436
- }
437
- // Fallback: store uid prefix for lazy timestamp generation in wrapStreamFn.
438
- // This ensures each model call gets a fresh timestamp instead of reusing
439
- // the same one across tool-use loops and retries.
440
427
  const uid = getUidFromConfig(ctx.config);
441
428
  if (!uid)
442
429
  return undefined;
@@ -487,62 +474,61 @@ export const xiaoyiProvider = {
487
474
  }
488
475
  }
489
476
  // ── Build dynamic headers ────────────────────────────
490
- if (ctx.extraParams) {
491
- const fallbackPrefix = ctx.extraParams[FALLBACK_PREFIX_KEY];
477
+ // Priority:
478
+ // 1. Cron-triggered: uid → cronUuid, with cron-specific headers
479
+ // 2. Xiaoyi A2A: taskId extracted from Conversation info (xiaoyi_ prefix)
480
+ // 3. UID-based fallback: sha256(uid).hex[:32]_timestamp
481
+ const isCron = isCronTriggered(context.messages);
482
+ if (isCron) {
483
+ const fallbackPrefix = ctx.extraParams?.[FALLBACK_PREFIX_KEY];
492
484
  if (typeof fallbackPrefix === "string") {
493
- // Fallback mode: generate fresh timestamp per request
494
- const isCron = isCronTriggered(context.messages);
495
485
  const fallbackValue = `${fallbackPrefix}_${Date.now()}`;
496
- dynamicHeaders[HEADER_TRACE_ID] = isCron ? `cron_${fallbackValue}` : fallbackValue;
486
+ dynamicHeaders[HEADER_TRACE_ID] = `cron_${fallbackValue}`;
497
487
  dynamicHeaders[HEADER_SESSION_ID] = fallbackValue;
498
488
  dynamicHeaders[HEADER_INTERACTION_ID] = fallbackValue;
499
- if (isCron) {
500
- const cronTitle = extractCronTitle(context.messages);
501
- if (cronTitle)
502
- dynamicHeaders["x-cron-title"] = encodeURIComponent(cronTitle);
503
- if (context.messages?.length === 1)
504
- dynamicHeaders["x-cron-flag"] = "begin";
505
- }
506
- }
507
- else if (extractedTaskId) {
508
- // Session mode: taskId extracted from Conversation info
509
- const traceId = extractedTaskId;
510
- const sessionId = traceId.split("&")[0];
511
- const interactionId = traceId.split("&")[1] ?? "";
512
- const isCron = isCronTriggered(context.messages);
513
- dynamicHeaders[HEADER_TRACE_ID] = isCron ? `cron_${traceId}_${Date.now()}` : traceId;
514
- if (isCron) {
515
- const cronTitle = extractCronTitle(context.messages);
516
- if (cronTitle)
517
- dynamicHeaders["x-cron-title"] = encodeURIComponent(cronTitle);
518
- if (context.messages?.length === 1)
519
- dynamicHeaders["x-cron-flag"] = "begin";
520
- }
521
- dynamicHeaders[HEADER_SESSION_ID] = sessionId;
522
- dynamicHeaders[HEADER_INTERACTION_ID] = interactionId;
523
489
  }
524
490
  else {
525
- // Fallback: use extraParams cached values
526
- const traceId = ctx.extraParams[HEADER_TRACE_ID];
527
- const sessionId = ctx.extraParams[HEADER_SESSION_ID];
528
- const interactionId = ctx.extraParams[HEADER_INTERACTION_ID];
529
- if (typeof traceId === "string")
530
- dynamicHeaders[HEADER_TRACE_ID] = traceId;
531
- if (typeof sessionId === "string")
532
- dynamicHeaders[HEADER_SESSION_ID] = sessionId;
533
- if (typeof interactionId === "string")
534
- dynamicHeaders[HEADER_INTERACTION_ID] = interactionId;
491
+ const cronUuid = extractCronUuid(context.messages) ?? "cron";
492
+ const cronSessionId = `cron_${cronUuid}_${Date.now()}`;
493
+ dynamicHeaders[HEADER_TRACE_ID] = cronSessionId;
494
+ dynamicHeaders[HEADER_SESSION_ID] = cronUuid;
495
+ dynamicHeaders[HEADER_INTERACTION_ID] = cronSessionId;
496
+ }
497
+ const cronTitle = extractCronTitle(context.messages);
498
+ if (cronTitle)
499
+ dynamicHeaders["x-cron-title"] = encodeURIComponent(cronTitle);
500
+ if (context.messages?.length === 1)
501
+ dynamicHeaders["x-cron-flag"] = "begin";
502
+ }
503
+ else if (extractedTaskId) {
504
+ const sessionId = extractedTaskId.split("&")[0];
505
+ const interactionId = extractedTaskId.split("&")[1] ?? "";
506
+ dynamicHeaders[HEADER_TRACE_ID] = extractedTaskId;
507
+ dynamicHeaders[HEADER_SESSION_ID] = sessionId;
508
+ dynamicHeaders[HEADER_INTERACTION_ID] = interactionId;
509
+ }
510
+ else {
511
+ const fallbackPrefix = ctx.extraParams?.[FALLBACK_PREFIX_KEY];
512
+ if (typeof fallbackPrefix === "string") {
513
+ const fallbackValue = `${fallbackPrefix}_${Date.now()}`;
514
+ dynamicHeaders[HEADER_TRACE_ID] = fallbackValue;
515
+ dynamicHeaders[HEADER_SESSION_ID] = fallbackValue;
516
+ dynamicHeaders[HEADER_INTERACTION_ID] = fallbackValue;
535
517
  }
536
518
  }
537
519
  // 记录输入
538
520
  logger.log(`[xiaoyiprovider] input messages count: ${context.messages?.length ?? 0}`);
521
+ // 🔑 通知 steer 队列:模型 API 已被调用,此时 isStreaming 一定为 true
522
+ const sessionCtx = getCurrentSessionContext();
523
+ if (sessionCtx?.sessionId) {
524
+ notifyModelStreaming(sessionCtx.sessionId);
525
+ }
539
526
  if (context.systemPrompt) {
540
527
  logger.log(`[xiaoyiprovider] system prompt length: ${context.systemPrompt.length}`);
541
528
  }
542
529
  // deviceType: prefer value extracted from Conversation info,
543
- // then extraParams, then ALS fallback.
544
- const extraParamsDeviceType = ctx.extraParams?.[DEVICE_TYPE_KEY] || undefined;
545
- const deviceType = (extractedDeviceType || extraParamsDeviceType)
530
+ // then ALS fallback.
531
+ const deviceType = extractedDeviceType
546
532
  ?? getCurrentSessionContext()?.deviceType;
547
533
  // 在发送给模型前,优化 systemPrompt 结构
548
534
  if (context.systemPrompt) {
@@ -8,7 +8,7 @@ import { getCurrentTaskId, getCurrentMessageId } from "../task-manager.js";
8
8
  * 仅用于全局 Map 回退路径的清理,不影响 ALS 路径。
9
9
  * 工具已改为闭包捕获 ctx,此 TTL 仅作为防止 session 泄漏的最后防线。
10
10
  * 正常对话中 registerSession 会刷新 createdAt,所以长对话不受影响。 */
11
- const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour
11
+ const SESSION_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours
12
12
  // Use globalThis to ensure a single Map instance across all module copies.
13
13
  // The xy_channel plugin may be loaded by openclaw from different module resolution
14
14
  // paths (plugin entry vs tool registration), causing session-manager.ts to be
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ynhcj/xiaoyi-channel",
3
- "version": "0.0.138-beta",
3
+ "version": "0.0.138-next",
4
4
  "description": "OpenClaw Xiaoyi Channel plugin - Xiaoyi A2A protocol integration",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",