@ynhcj/xiaoyi-channel 0.0.131-beta → 0.0.132-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/src/bot.d.ts CHANGED
@@ -9,6 +9,8 @@ export interface HandleXYMessageParams {
9
9
  message: A2AJsonRpcRequest;
10
10
  accountId: string;
11
11
  webSocketSessionId?: string;
12
+ /** Called after dispatch init is complete (agentTools/wrapStreamFn done). */
13
+ onInitComplete?: () => void;
12
14
  }
13
15
  /**
14
16
  * Handle an incoming A2A message.
package/dist/src/bot.js CHANGED
@@ -236,7 +236,7 @@ export async function handleXYMessage(params) {
236
236
  SenderId: parsed.sessionId,
237
237
  Provider: "xiaoyi-channel",
238
238
  Surface: "xiaoyi-channel",
239
- MessageSid: parsed.messageId,
239
+ MessageSid: `${parsed.taskId}_${deviceType}`,
240
240
  Timestamp: Date.now(),
241
241
  WasMentioned: false,
242
242
  CommandAuthorized: true,
@@ -289,34 +289,42 @@ export async function handleXYMessage(params) {
289
289
  unregisterSession(route.sessionKey);
290
290
  log(`[BOT] ✅ Cleanup completed`);
291
291
  },
292
- run: () =>
293
- // 🔐 Use AsyncLocalStorage to provide session context to tools
294
- runWithSessionContext(sessionContext, async () => {
295
- log(`[BOT-DISPATCH] dispatchReplyFromConfig starting...`);
296
- log(`[BOT-DISPATCH] - sessionKey: ${ctxPayload.SessionKey}`);
297
- log(`[BOT-DISPATCH] - provider: ${ctxPayload.Provider}`);
298
- log(`[BOT-DISPATCH] - surface: ${ctxPayload.Surface}`);
299
- log(`[BOT-DISPATCH] - from: ${ctxPayload.From}`);
300
- log(`[BOT-DISPATCH] - body length: ${ctxPayload.Body?.length ?? 0}`);
301
- try {
302
- const result = await core.channel.reply.dispatchReplyFromConfig({
303
- ctx: ctxPayload,
304
- cfg,
305
- dispatcher,
306
- replyOptions,
307
- });
308
- log(`[BOT-DISPATCH] ✅ dispatchReplyFromConfig returned`);
309
- log(`[BOT-DISPATCH] - result: ${JSON.stringify(result)}`);
310
- return result;
311
- }
312
- catch (dispatchErr) {
313
- error(`[BOT-DISPATCH] dispatchReplyFromConfig threw`);
314
- error(`[BOT-DISPATCH] - error name: ${dispatchErr instanceof Error ? dispatchErr.name : "unknown"}`);
315
- error(`[BOT-DISPATCH] - error message: ${String(dispatchErr)}`);
316
- error(`[BOT-DISPATCH] - error stack: ${dispatchErr instanceof Error ? dispatchErr.stack?.slice(0, 500) : "N/A"}`);
317
- throw dispatchErr;
318
- }
319
- }),
292
+ run: () => {
293
+ // 🔐 Use AsyncLocalStorage to provide session context to tools.
294
+ // runWithSessionContext returns after the sync part of dispatch
295
+ // (including agentTools + wrapStreamFn) has executed, so we
296
+ // signal init complete to release the global dispatch gate
297
+ // for the next session.
298
+ const dispatchPromise = runWithSessionContext(sessionContext, async () => {
299
+ log(`[BOT-DISPATCH] dispatchReplyFromConfig starting...`);
300
+ log(`[BOT-DISPATCH] - sessionKey: ${ctxPayload.SessionKey}`);
301
+ log(`[BOT-DISPATCH] - provider: ${ctxPayload.Provider}`);
302
+ log(`[BOT-DISPATCH] - surface: ${ctxPayload.Surface}`);
303
+ log(`[BOT-DISPATCH] - from: ${ctxPayload.From}`);
304
+ log(`[BOT-DISPATCH] - body length: ${ctxPayload.Body?.length ?? 0}`);
305
+ try {
306
+ const result = await core.channel.reply.dispatchReplyFromConfig({
307
+ ctx: ctxPayload,
308
+ cfg,
309
+ dispatcher,
310
+ replyOptions,
311
+ });
312
+ log(`[BOT-DISPATCH] ✅ dispatchReplyFromConfig returned`);
313
+ log(`[BOT-DISPATCH] - result: ${JSON.stringify(result)}`);
314
+ return result;
315
+ }
316
+ catch (dispatchErr) {
317
+ error(`[BOT-DISPATCH] ❌ dispatchReplyFromConfig threw`);
318
+ error(`[BOT-DISPATCH] - error name: ${dispatchErr instanceof Error ? dispatchErr.name : "unknown"}`);
319
+ error(`[BOT-DISPATCH] - error message: ${String(dispatchErr)}`);
320
+ error(`[BOT-DISPATCH] - error stack: ${dispatchErr instanceof Error ? dispatchErr.stack?.slice(0, 500) : "N/A"}`);
321
+ throw dispatchErr;
322
+ }
323
+ });
324
+ // Signal init complete — sync part (agentTools, wrapStreamFn) is done
325
+ params.onInitComplete?.();
326
+ return dispatchPromise;
327
+ },
320
328
  });
321
329
  log(`[BOT] ✅ Dispatcher completed for session: ${parsed.sessionId}`);
322
330
  log(`xy: dispatch complete (session=${parsed.sessionId})`);
@@ -68,6 +68,12 @@ export async function monitorXYProvider(opts = {}) {
68
68
  const activeMessages = new Set();
69
69
  // Create session queue for ordered message processing
70
70
  const enqueue = createSessionQueue();
71
+ // Global gate that serializes dispatch initialization across sessions.
72
+ // When a new session starts dispatching, it acquires this gate and holds it
73
+ // until agent setup (agentTools + wrapStreamFn) is complete, then releases it.
74
+ // This prevents lastRegisteredKey races when multiple sessions initialize
75
+ // concurrently.
76
+ let globalDispatchInitGate = Promise.resolve();
71
77
  // Health check interval
72
78
  let healthCheckInterval = null;
73
79
  return new Promise((resolve, reject) => {
@@ -83,6 +89,11 @@ export async function monitorXYProvider(opts = {}) {
83
89
  }
84
90
  activeMessages.add(messageKey);
85
91
  const task = async () => {
92
+ // Wait for the previous session's init to complete (global gate),
93
+ // then acquire the gate for this session's init.
94
+ await globalDispatchInitGate;
95
+ let releaseGate;
96
+ globalDispatchInitGate = new Promise((r) => { releaseGate = r; });
86
97
  try {
87
98
  await handleXYMessage({
88
99
  cfg,
@@ -90,10 +101,12 @@ export async function monitorXYProvider(opts = {}) {
90
101
  message,
91
102
  accountId, // ✅ Pass accountId ("default")
92
103
  webSocketSessionId: sessionId, // ✅ 传递 WebSocket 层级的 sessionId
104
+ onInitComplete: () => releaseGate(),
93
105
  });
94
106
  }
95
107
  catch (err) {
96
108
  // ✅ Only log error, don't re-throw to prevent gateway restart
109
+ releaseGate();
97
110
  error(`XY gateway: error handling message from ${serverId}: ${String(err)}`);
98
111
  }
99
112
  finally {
@@ -9,7 +9,6 @@
9
9
  // models.providers.xiaoyiprovider.models = [...]
10
10
  import { createHash } from "crypto";
11
11
  import { getCurrentSessionContext } from "./tools/session-manager.js";
12
- import { getCurrentTaskId } from "./task-manager.js";
13
12
  import { selfEvolutionManager } from "./utils/self-evolution-manager.js";
14
13
  // ── Retry config ──────────────────────────────────────────────
15
14
  const RETRY_DELAYS_MS = [10_000, 20_000, 40_000, 60_000, 60_000];
@@ -389,6 +388,22 @@ function trimUserMetadata(text) {
389
388
  text = text.replace(/\n*Sender \(untrusted metadata\):\n```json\n[\s\S]*?\n```\n*/, "\n");
390
389
  return text.replace(/\n{3,}/g, "\n\n");
391
390
  }
391
+ /**
392
+ * Extract A2A taskId and deviceType from Conversation info JSON.
393
+ * bot.ts stores them as MessageSid = "taskId_deviceType".
394
+ */
395
+ function extractA2AFromConversationInfo(text) {
396
+ const match = text.match(/Conversation info \(untrusted metadata\):\n```json\n([\s\S]*?)\n```/);
397
+ if (!match)
398
+ return null;
399
+ const msgIdMatch = match[1].match(/"message_id"\s*:\s*"([^"]+)"/);
400
+ if (!msgIdMatch)
401
+ return null;
402
+ const parts = msgIdMatch[1].split("_");
403
+ if (parts.length < 2)
404
+ return null;
405
+ return { taskId: parts[0], deviceType: parts[1] };
406
+ }
392
407
  export const xiaoyiProvider = {
393
408
  id: "xiaoyiprovider",
394
409
  label: "Xiaoyi Provider",
@@ -443,17 +458,34 @@ export const xiaoyiProvider = {
443
458
  const underlying = ctx.streamFn;
444
459
  if (!underlying)
445
460
  return underlying;
446
- // Capture A2A sessionId at agent setup time for multi-session isolation.
447
- // openclaw calls wrapStreamFn per-agent (per session), so this runs inside
448
- // the correct runWithSessionContext() ALS scope. When multiple sessions are
449
- // active concurrently, getCurrentSessionContext() may later return the WRONG
450
- // session (lastRegisteredKey fallback). The captured sessionId lets us
451
- // bypass that fallback and look up the correct taskId directly from
452
- // task-manager.
453
- const capturedA2ASessionId = getCurrentSessionContext()?.sessionId ?? null;
454
461
  return async (model, context, options) => {
455
- // 每次请求时从 ctx.extraParams 动态读取 header
456
462
  const dynamicHeaders = {};
463
+ // ── Extract A2A taskId/deviceType from Conversation info ──
464
+ // bot.ts stores taskId_deviceType as MessageSid, which the framework
465
+ // renders as message_id in the Conversation info JSON block.
466
+ let extractedTaskId = null;
467
+ let extractedDeviceType = null;
468
+ if (context.messages) {
469
+ for (let i = context.messages.length - 1; i >= 0; i--) {
470
+ const msg = context.messages[i];
471
+ if (msg.role !== "user")
472
+ continue;
473
+ const text = typeof msg.content === "string"
474
+ ? msg.content
475
+ : Array.isArray(msg.content)
476
+ ? msg.content.find((b) => b.type === "text")?.text ?? ""
477
+ : "";
478
+ if (!text)
479
+ continue;
480
+ const extracted = extractA2AFromConversationInfo(text);
481
+ if (extracted) {
482
+ extractedTaskId = extracted.taskId;
483
+ extractedDeviceType = extracted.deviceType;
484
+ break;
485
+ }
486
+ }
487
+ }
488
+ // ── Build dynamic headers ────────────────────────────
457
489
  if (ctx.extraParams) {
458
490
  const fallbackPrefix = ctx.extraParams[FALLBACK_PREFIX_KEY];
459
491
  if (typeof fallbackPrefix === "string") {
@@ -471,40 +503,30 @@ export const xiaoyiProvider = {
471
503
  dynamicHeaders["x-cron-flag"] = "begin";
472
504
  }
473
505
  }
474
- else {
475
- // Session mode: resolve taskId for the correct session.
476
- //
477
- // Priority:
478
- // 1. capturedA2ASessionId getCurrentTaskId() (most reliable,
479
- // bypasses lastRegisteredKey fallback)
480
- // 2. getCurrentSessionContext()?.taskId (works when ALS
481
- // is intact)
482
- // 3. ctx.extraParams cached values (last resort,
483
- // may be stale / from wrong session)
484
- let resolvedTaskId = null;
485
- if (capturedA2ASessionId) {
486
- resolvedTaskId = getCurrentTaskId(capturedA2ASessionId);
487
- }
488
- if (!resolvedTaskId) {
489
- resolvedTaskId = getCurrentSessionContext()?.taskId ?? null;
490
- }
491
- const traceId = resolvedTaskId ?? ctx.extraParams[HEADER_TRACE_ID];
492
- const sessionId = resolvedTaskId?.split("&")[0]
493
- ?? ctx.extraParams[HEADER_SESSION_ID];
494
- const interactionId = resolvedTaskId?.split("&")[1]
495
- ?? ctx.extraParams[HEADER_INTERACTION_ID]
496
- ?? "";
497
- if (typeof traceId === "string") {
498
- const isCron = isCronTriggered(context.messages);
499
- dynamicHeaders[HEADER_TRACE_ID] = isCron ? `cron_${traceId}_${Date.now()}` : traceId;
500
- if (isCron) {
501
- const cronTitle = extractCronTitle(context.messages);
502
- if (cronTitle)
503
- dynamicHeaders["x-cron-title"] = encodeURIComponent(cronTitle);
504
- if (context.messages?.length === 1)
505
- dynamicHeaders["x-cron-flag"] = "begin";
506
- }
506
+ else if (extractedTaskId) {
507
+ // Session mode: taskId extracted from Conversation info
508
+ const traceId = extractedTaskId;
509
+ const sessionId = traceId.split("&")[0];
510
+ const interactionId = traceId.split("&")[1] ?? "";
511
+ const isCron = isCronTriggered(context.messages);
512
+ dynamicHeaders[HEADER_TRACE_ID] = isCron ? `cron_${traceId}_${Date.now()}` : traceId;
513
+ if (isCron) {
514
+ const cronTitle = extractCronTitle(context.messages);
515
+ if (cronTitle)
516
+ dynamicHeaders["x-cron-title"] = encodeURIComponent(cronTitle);
517
+ if (context.messages?.length === 1)
518
+ dynamicHeaders["x-cron-flag"] = "begin";
507
519
  }
520
+ dynamicHeaders[HEADER_SESSION_ID] = sessionId;
521
+ dynamicHeaders[HEADER_INTERACTION_ID] = interactionId;
522
+ }
523
+ else {
524
+ // Fallback: use extraParams cached values
525
+ const traceId = ctx.extraParams[HEADER_TRACE_ID];
526
+ const sessionId = ctx.extraParams[HEADER_SESSION_ID];
527
+ const interactionId = ctx.extraParams[HEADER_INTERACTION_ID];
528
+ if (typeof traceId === "string")
529
+ dynamicHeaders[HEADER_TRACE_ID] = traceId;
508
530
  if (typeof sessionId === "string")
509
531
  dynamicHeaders[HEADER_SESSION_ID] = sessionId;
510
532
  if (typeof interactionId === "string")
@@ -516,13 +538,11 @@ export const xiaoyiProvider = {
516
538
  if (context.systemPrompt) {
517
539
  console.log(`[xiaoyiprovider] system prompt length: ${context.systemPrompt.length}`);
518
540
  }
519
- // Prefer deviceType from extraParams (set by prepareExtraParams).
520
- // Fall back to getCurrentSessionContext() because OpenClaw caches
521
- // resolvePreparedExtraParams by provider/modelId – the cache key does
522
- // not include session-specific data, so deviceType may be missing
523
- // from the cached extraParams even when a session is active.
541
+ // deviceType: prefer value extracted from Conversation info,
542
+ // then extraParams, then ALS fallback.
524
543
  const extraParamsDeviceType = ctx.extraParams?.[DEVICE_TYPE_KEY] || undefined;
525
- const deviceType = extraParamsDeviceType ?? getCurrentSessionContext()?.deviceType;
544
+ const deviceType = (extractedDeviceType || extraParamsDeviceType)
545
+ ?? getCurrentSessionContext()?.deviceType;
526
546
  // 在发送给模型前,优化 systemPrompt 结构
527
547
  if (context.systemPrompt) {
528
548
  let sp = context.systemPrompt;
@@ -555,7 +575,7 @@ export const xiaoyiProvider = {
555
575
  const selfEvolutionEnabled = await selfEvolutionManager.isEnabled();
556
576
  console.log(`[selfEvolution] selfEvolution flag: ${selfEvolutionEnabled}`);
557
577
  context.systemPrompt = applySelfEvolutionPrompt(context.systemPrompt, selfEvolutionEnabled);
558
- // Append device context to systemPrompt (using pre-captured deviceType from prepareExtraParams)
578
+ // Append device context to systemPrompt
559
579
  if (deviceType) {
560
580
  const displayDevice = (deviceType === "2in1") ? "鸿蒙PC" : deviceType;
561
581
  const deviceSection = `\n\n## Current User Device Context\nThe current user is using the following device: ${displayDevice}\nYou need to be aware of the user's current device and provide guidance accordingly. If the response involves device-related tools or actions, you must tailor the reply based on the user's current device, using device-specific references such as "saved to the Notes/Calendar on your {deviceType}.\n"`;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Tool session helper — provides a simple API for tools to access
3
+ * the current session context via sessionKey.
4
+ *
5
+ * Tools capture `sessionKey` at creation time (may be empty if ALS
6
+ * was not available at agentTools factory time) and call
7
+ * `requireSession()` at execute time. If the captured sessionKey
8
+ * doesn't resolve, we fall back to ALS — the agent run is always
9
+ * within the ALS context set by bot.ts.
10
+ */
11
+ import { type XYSession } from "../xy-session-store.js";
12
+ /**
13
+ * Get the session for a sessionKey, throwing if not found.
14
+ *
15
+ * Resolution order:
16
+ * 1. Closure-captured sessionKey (set at agentTools factory time)
17
+ * 2. ALS (set by bot.ts before the agent run)
18
+ * 3. Store enumeration — safe only when a single session is active
19
+ *
20
+ * Layers 1 and 2 can both fail: agentTools may be called before ALS is
21
+ * active, and pi-agent-core tool execute may cross async boundaries that
22
+ * lose the ALS context. Layer 3 is the ultimate fallback.
23
+ */
24
+ export declare function requireSession(sessionKey?: string | null): XYSession;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Tool session helper — provides a simple API for tools to access
3
+ * the current session context via sessionKey.
4
+ *
5
+ * Tools capture `sessionKey` at creation time (may be empty if ALS
6
+ * was not available at agentTools factory time) and call
7
+ * `requireSession()` at execute time. If the captured sessionKey
8
+ * doesn't resolve, we fall back to ALS — the agent run is always
9
+ * within the ALS context set by bot.ts.
10
+ */
11
+ import { getSession, xyAsyncLocalStorage, getAllActiveSessions } from "../xy-session-store.js";
12
+ /**
13
+ * Get the session for a sessionKey, throwing if not found.
14
+ *
15
+ * Resolution order:
16
+ * 1. Closure-captured sessionKey (set at agentTools factory time)
17
+ * 2. ALS (set by bot.ts before the agent run)
18
+ * 3. Store enumeration — safe only when a single session is active
19
+ *
20
+ * Layers 1 and 2 can both fail: agentTools may be called before ALS is
21
+ * active, and pi-agent-core tool execute may cross async boundaries that
22
+ * lose the ALS context. Layer 3 is the ultimate fallback.
23
+ */
24
+ export function requireSession(sessionKey) {
25
+ // Layer 1: closure-captured sessionKey
26
+ if (sessionKey) {
27
+ const session = getSession(sessionKey);
28
+ if (session)
29
+ return session;
30
+ }
31
+ // Layer 2: ALS (works when the async chain preserves the context)
32
+ const alsContext = xyAsyncLocalStorage.getStore();
33
+ const alsKey = alsContext?.openclawSessionKey;
34
+ if (alsKey) {
35
+ const session = getSession(alsKey);
36
+ if (session)
37
+ return session;
38
+ }
39
+ // Layer 3: store enumeration — reliable when only one session is active
40
+ const allSessions = getAllActiveSessions();
41
+ if (allSessions.length === 1) {
42
+ return allSessions[0].session;
43
+ }
44
+ throw new Error(`XY session not found (sessionKey=${sessionKey ?? "none"}, alsKey=${alsKey ?? "none"}, activeSessions=${allSessions.length})`);
45
+ }
@@ -1,3 +1,4 @@
1
+ import { AsyncLocalStorage } from "async_hooks";
1
2
  import type { XYChannelConfig } from "../types.js";
2
3
  export interface SessionContext {
3
4
  config: XYChannelConfig;
@@ -7,6 +8,12 @@ export interface SessionContext {
7
8
  agentId: string;
8
9
  deviceType?: string;
9
10
  }
11
+ interface SessionContextWithRef extends SessionContext {
12
+ refCount: number;
13
+ createdAt: number;
14
+ }
15
+ export declare const activeSessions: Map<string, SessionContextWithRef>;
16
+ export declare const asyncLocalStorage: AsyncLocalStorage<SessionContext>;
10
17
  /**
11
18
  * Register a session context for tool access.
12
19
  * Should be called when starting to process a message.
@@ -58,3 +65,4 @@ export declare function cleanupStaleSessions(): number;
58
65
  * Get the current number of active sessions (for diagnostics).
59
66
  */
60
67
  export declare function getActiveSessionCount(): number;
68
+ export {};
@@ -18,7 +18,7 @@ const _g = globalThis;
18
18
  if (!_g.__xyActiveSessions) {
19
19
  _g.__xyActiveSessions = new Map();
20
20
  }
21
- const activeSessions = _g.__xyActiveSessions;
21
+ export const activeSessions = _g.__xyActiveSessions;
22
22
  // Track the most recently registered sessionKey for reliable fallback
23
23
  // when AsyncLocalStorage context is lost across openclaw's embedded runner boundary.
24
24
  if (!_g.__xyLastRegisteredSessionKey) {
@@ -27,7 +27,7 @@ if (!_g.__xyLastRegisteredSessionKey) {
27
27
  const getLastRegisteredKey = () => _g.__xyLastRegisteredSessionKey;
28
28
  const setLastRegisteredKey = (key) => { _g.__xyLastRegisteredSessionKey = key; };
29
29
  // AsyncLocalStorage for thread-safe session context isolation
30
- const asyncLocalStorage = new AsyncLocalStorage();
30
+ export const asyncLocalStorage = new AsyncLocalStorage();
31
31
  /**
32
32
  * Register a session context for tool access.
33
33
  * Should be called when starting to process a message.
@@ -0,0 +1,79 @@
1
+ /**
2
+ * XYSessionStore — unified session state management.
3
+ *
4
+ * Replaces the old session-manager.ts + task-manager.ts + tool-context.ts
5
+ * with a single Map<sessionKey, XYSession>.
6
+ *
7
+ * Design:
8
+ * - Closures in tools/providers capture only `sessionKey` (a string).
9
+ * - Actual values (taskId, messageId, …) are looked up at execution time
10
+ * so steer-mode updates are always visible.
11
+ * - No refCount / lock — cleanup is driven by onSettled callbacks.
12
+ * - globalThis protection guards against duplicate module instances.
13
+ */
14
+ import type { XYChannelConfig } from "./types.js";
15
+ import { AsyncLocalStorage } from "node:async_hooks";
16
+ export interface XYSession {
17
+ config: XYChannelConfig;
18
+ /** A2A protocol sessionId — identifies the conversation. */
19
+ a2aSessionId: string;
20
+ /** A2A taskId — updated in-place on steer. */
21
+ taskId: string;
22
+ /** A2A messageId — updated in-place on steer. */
23
+ messageId: string;
24
+ /** Device type (phone / tablet / 2in1). */
25
+ deviceType?: string;
26
+ /** OpenClaw accountId from route resolution ("default"). */
27
+ accountId: string;
28
+ }
29
+ export interface XYSessionALSContext {
30
+ openclawSessionKey: string;
31
+ }
32
+ export declare const xyAsyncLocalStorage: AsyncLocalStorage<XYSessionALSContext>;
33
+ /**
34
+ * Register or update a session.
35
+ * Called from bot.ts after resolveAgentRoute().
36
+ */
37
+ export declare function registerSession(sessionKey: string, session: XYSession): void;
38
+ /**
39
+ * Update specific fields of an active session (used for steer).
40
+ */
41
+ export declare function updateSession(sessionKey: string, partial: Partial<Pick<XYSession, "taskId" | "messageId" | "deviceType">>): void;
42
+ /**
43
+ * Get session by OpenClaw sessionKey.
44
+ * Returns null if not found.
45
+ */
46
+ export declare function getSession(sessionKey: string): XYSession | null;
47
+ /**
48
+ * Get session by A2A sessionId.
49
+ * Used by provider.ts which only knows the A2A sessionId.
50
+ */
51
+ export declare function getSessionByA2AId(a2aSessionId: string): XYSession | null;
52
+ /**
53
+ * Unregister a session. Called from onSettled / error cleanup.
54
+ */
55
+ export declare function unregisterSession(sessionKey: string): void;
56
+ /**
57
+ * Check whether a session has an active task (used for steer detection).
58
+ */
59
+ export declare function hasActiveSession(sessionKey: string): boolean;
60
+ /**
61
+ * Get all active sessions (for gateway stop / diagnostics).
62
+ */
63
+ export declare function getAllActiveSessions(): Array<{
64
+ sessionKey: string;
65
+ session: XYSession;
66
+ }>;
67
+ /**
68
+ * Get count of active sessions.
69
+ */
70
+ export declare function getActiveSessionCount(): number;
71
+ /**
72
+ * Force-clean all sessions (gateway shutdown / reload).
73
+ */
74
+ export declare function cleanupAllSessions(): void;
75
+ /**
76
+ * Clean up stale sessions (older than TTL).
77
+ * Returns number of cleaned sessions.
78
+ */
79
+ export declare function cleanupStaleSessions(ttlMs?: number): number;
@@ -0,0 +1,153 @@
1
+ import { logger } from "./utils/logger.js";
2
+ import { AsyncLocalStorage } from "node:async_hooks";
3
+ // ── AsyncLocalStorage ─────────────────────────────────────────
4
+ // Used by bot.ts to carry sessionKey through the agent run so
5
+ // channel.ts agentTools factory can read it.
6
+ export const xyAsyncLocalStorage = new AsyncLocalStorage();
7
+ const STORE_KEY = "__xySessionStore";
8
+ const A2A_INDEX_KEY = "__xyA2ASessionIndex";
9
+ const _g = globalThis;
10
+ function getStore() {
11
+ if (!_g[STORE_KEY]) {
12
+ _g[STORE_KEY] = new Map();
13
+ }
14
+ return _g[STORE_KEY];
15
+ }
16
+ /** Reverse index: a2aSessionId → openclawSessionKey (for provider lookups). */
17
+ function getA2AIndex() {
18
+ if (!_g[A2A_INDEX_KEY]) {
19
+ _g[A2A_INDEX_KEY] = new Map();
20
+ }
21
+ return _g[A2A_INDEX_KEY];
22
+ }
23
+ // ── API ───────────────────────────────────────────────────────
24
+ /**
25
+ * Register or update a session.
26
+ * Called from bot.ts after resolveAgentRoute().
27
+ */
28
+ export function registerSession(sessionKey, session) {
29
+ const store = getStore();
30
+ const a2aIndex = getA2AIndex();
31
+ const existing = store.get(sessionKey);
32
+ if (existing) {
33
+ // Update in place — steer mode
34
+ existing.taskId = session.taskId;
35
+ existing.messageId = session.messageId;
36
+ existing.deviceType = session.deviceType;
37
+ existing.createdAt = Date.now();
38
+ logger.log(`[SESSION-STORE] update: sessionKey=${sessionKey} a2a=${session.a2aSessionId} taskId=${session.taskId}`);
39
+ }
40
+ else {
41
+ store.set(sessionKey, { ...session, createdAt: Date.now() });
42
+ a2aIndex.set(session.a2aSessionId, sessionKey);
43
+ logger.log(`[SESSION-STORE] register: sessionKey=${sessionKey} a2a=${session.a2aSessionId} taskId=${session.taskId}`);
44
+ }
45
+ }
46
+ /**
47
+ * Update specific fields of an active session (used for steer).
48
+ */
49
+ export function updateSession(sessionKey, partial) {
50
+ const store = getStore();
51
+ const entry = store.get(sessionKey);
52
+ if (!entry) {
53
+ logger.log(`[SESSION-STORE] update skipped: sessionKey=${sessionKey} not found`);
54
+ return;
55
+ }
56
+ if (partial.taskId !== undefined)
57
+ entry.taskId = partial.taskId;
58
+ if (partial.messageId !== undefined)
59
+ entry.messageId = partial.messageId;
60
+ if (partial.deviceType !== undefined)
61
+ entry.deviceType = partial.deviceType;
62
+ logger.log(`[SESSION-STORE] update: sessionKey=${sessionKey} taskId=${entry.taskId}`);
63
+ }
64
+ /**
65
+ * Get session by OpenClaw sessionKey.
66
+ * Returns null if not found.
67
+ */
68
+ export function getSession(sessionKey) {
69
+ const store = getStore();
70
+ const entry = store.get(sessionKey);
71
+ if (!entry)
72
+ return null;
73
+ // Strip internal fields
74
+ const { createdAt, ...session } = entry;
75
+ return session;
76
+ }
77
+ /**
78
+ * Get session by A2A sessionId.
79
+ * Used by provider.ts which only knows the A2A sessionId.
80
+ */
81
+ export function getSessionByA2AId(a2aSessionId) {
82
+ const a2aIndex = getA2AIndex();
83
+ const sessionKey = a2aIndex.get(a2aSessionId);
84
+ if (!sessionKey)
85
+ return null;
86
+ return getSession(sessionKey);
87
+ }
88
+ /**
89
+ * Unregister a session. Called from onSettled / error cleanup.
90
+ */
91
+ export function unregisterSession(sessionKey) {
92
+ const store = getStore();
93
+ const a2aIndex = getA2AIndex();
94
+ const entry = store.get(sessionKey);
95
+ if (!entry)
96
+ return;
97
+ a2aIndex.delete(entry.a2aSessionId);
98
+ store.delete(sessionKey);
99
+ logger.log(`[SESSION-STORE] unregister: sessionKey=${sessionKey}`);
100
+ }
101
+ /**
102
+ * Check whether a session has an active task (used for steer detection).
103
+ */
104
+ export function hasActiveSession(sessionKey) {
105
+ return getStore().has(sessionKey);
106
+ }
107
+ /**
108
+ * Get all active sessions (for gateway stop / diagnostics).
109
+ */
110
+ export function getAllActiveSessions() {
111
+ const store = getStore();
112
+ const result = [];
113
+ for (const [key, entry] of store) {
114
+ const { createdAt, ...session } = entry;
115
+ result.push({ sessionKey: key, session });
116
+ }
117
+ return result;
118
+ }
119
+ /**
120
+ * Get count of active sessions.
121
+ */
122
+ export function getActiveSessionCount() {
123
+ return getStore().size;
124
+ }
125
+ /**
126
+ * Force-clean all sessions (gateway shutdown / reload).
127
+ */
128
+ export function cleanupAllSessions() {
129
+ getStore().clear();
130
+ getA2AIndex().clear();
131
+ logger.log("[SESSION-STORE] all sessions cleaned up");
132
+ }
133
+ /**
134
+ * Clean up stale sessions (older than TTL).
135
+ * Returns number of cleaned sessions.
136
+ */
137
+ export function cleanupStaleSessions(ttlMs = 60 * 60 * 1000) {
138
+ const store = getStore();
139
+ const a2aIndex = getA2AIndex();
140
+ const now = Date.now();
141
+ let cleaned = 0;
142
+ for (const [key, entry] of store) {
143
+ if (now - entry.createdAt > ttlMs) {
144
+ a2aIndex.delete(entry.a2aSessionId);
145
+ store.delete(key);
146
+ cleaned++;
147
+ }
148
+ }
149
+ if (cleaned > 0) {
150
+ logger.log(`[SESSION-STORE] cleaned ${cleaned} stale session(s)`);
151
+ }
152
+ return cleaned;
153
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ynhcj/xiaoyi-channel",
3
- "version": "0.0.131-beta",
3
+ "version": "0.0.132-beta",
4
4
  "description": "OpenClaw Xiaoyi Channel plugin - Xiaoyi A2A protocol integration",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",