@yeaft/webchat-agent 0.1.62 → 0.1.63

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/claude.js CHANGED
@@ -1,7 +1,15 @@
1
1
  import { query, Stream } from './sdk/index.js';
2
2
  import ctx from './context.js';
3
3
  import { sendConversationList, sendOutput, sendError, handleAskUserQuestion } from './conversation.js';
4
- import { buildRolePlaySystemPrompt } from './roleplay.js';
4
+ import {
5
+ buildRolePlaySystemPrompt,
6
+ rolePlaySessions,
7
+ initRolePlayRouteState,
8
+ detectRoleSignal,
9
+ processRolePlayRoutes,
10
+ buildRouteEventMessage,
11
+ getRolePlayRouteState
12
+ } from './roleplay.js';
5
13
 
6
14
  /**
7
15
  * Start a Claude SDK query for a conversation
@@ -89,6 +97,12 @@ export async function startClaudeQuery(conversationId, workDir, resumeSessionId)
89
97
  if (savedRolePlayConfig) {
90
98
  options.appendSystemPrompt = buildRolePlaySystemPrompt(savedRolePlayConfig);
91
99
  console.log(`[SDK] RolePlay appendSystemPrompt injected (teamType: ${savedRolePlayConfig.teamType})`);
100
+
101
+ // Initialize RolePlay route state if session exists
102
+ const rpSession = rolePlaySessions.get(conversationId);
103
+ if (rpSession) {
104
+ initRolePlayRouteState(rpSession, state);
105
+ }
92
106
  }
93
107
 
94
108
  // Validate session ID is a valid UUID before using it
@@ -414,6 +428,51 @@ async function processClaudeOutput(conversationId, claudeQuery, state) {
414
428
  continue;
415
429
  }
416
430
 
431
+ // ★ RolePlay ROUTE detection: check accumulated text for ROUTE blocks
432
+ const rpSession = state.rolePlayConfig ? rolePlaySessions.get(conversationId) : null;
433
+ let roleplayAutoContinue = false;
434
+ let roleplayContinueRoles = [];
435
+
436
+ if (rpSession && rpSession._routeInitialized && state._roleplayAccumulated) {
437
+ const { routes, hasHumanRoute, continueRoles } = processRolePlayRoutes(
438
+ state._roleplayAccumulated, rpSession
439
+ );
440
+
441
+ if (routes.length > 0) {
442
+ // Send route events to frontend
443
+ for (const route of routes) {
444
+ const routeEvent = buildRouteEventMessage(
445
+ conversationId, rpSession.currentRole || 'unknown', route
446
+ );
447
+ ctx.sendToServer(routeEvent);
448
+ }
449
+
450
+ // Send route state update
451
+ const routeState = getRolePlayRouteState(conversationId);
452
+ if (routeState) {
453
+ ctx.sendToServer({
454
+ type: 'roleplay_status',
455
+ conversationId,
456
+ ...routeState
457
+ });
458
+ }
459
+
460
+ if (hasHumanRoute) {
461
+ // Stop auto-continue, wait for user input
462
+ ctx.sendToServer({
463
+ type: 'roleplay_waiting_human',
464
+ conversationId,
465
+ fromRole: rpSession.currentRole,
466
+ message: rpSession.waitingHumanContext?.message || ''
467
+ });
468
+ } else if (continueRoles.length > 0) {
469
+ // Auto-continue: pick the first route target and send the prompt
470
+ roleplayAutoContinue = true;
471
+ roleplayContinueRoles = continueRoles;
472
+ }
473
+ }
474
+ }
475
+
417
476
  // ★ Turn 完成:发送 turn_completed,进程继续运行等待下一条消息
418
477
  // stream-json 模式下 Claude 进程是持久运行的,for-await 在 result 后继续等待
419
478
  // 不清空 state.query 和 state.inputStream,下次用户消息直接通过同一个 inputStream 发送
@@ -424,6 +483,47 @@ async function processClaudeOutput(conversationId, claudeQuery, state) {
424
483
  // ★ await 确保 result 和 turn_completed 消息确实发送成功
425
484
  // 不 await 会导致 encrypt 失败时消息静默丢失,前端卡在"思考中"
426
485
  await sendOutput(conversationId, message);
486
+
487
+ // ★ RolePlay auto-continue: inject next role's prompt into the same conversation
488
+ if (roleplayAutoContinue && rpSession && state.inputStream) {
489
+ // Reset accumulated text for next turn
490
+ state._roleplayAccumulated = '';
491
+
492
+ for (const { to, prompt, taskId, taskTitle } of roleplayContinueRoles) {
493
+ rpSession.currentRole = to;
494
+ if (rpSession.roleStates[to]) {
495
+ rpSession.roleStates[to].status = 'active';
496
+ }
497
+
498
+ console.log(`[RolePlay] Auto-continuing to role: ${to}`);
499
+
500
+ // Re-activate the turn
501
+ state.turnActive = true;
502
+ state.turnResultReceived = false;
503
+
504
+ // Send the continuation prompt through the same input stream
505
+ const userMessage = {
506
+ type: 'user',
507
+ message: { role: 'user', content: prompt }
508
+ };
509
+ sendOutput(conversationId, userMessage);
510
+ state.inputStream.enqueue(userMessage);
511
+
512
+ // RolePlay uses a single conversation — only one target can be active
513
+ // at a time. Additional route targets are ignored.
514
+ break;
515
+ }
516
+
517
+ // Send status update (don't send turn_completed yet since we're continuing)
518
+ sendConversationList();
519
+ continue;
520
+ }
521
+
522
+ // Reset accumulated text
523
+ if (state._roleplayAccumulated !== undefined) {
524
+ state._roleplayAccumulated = '';
525
+ }
526
+
427
527
  await ctx.sendToServer({
428
528
  type: 'turn_completed',
429
529
  conversationId,
@@ -434,9 +534,61 @@ async function processClaudeOutput(conversationId, claudeQuery, state) {
434
534
  continue;
435
535
  }
436
536
 
537
+ // ★ RolePlay: accumulate assistant text and detect ROLE signals
538
+ if (state.rolePlayConfig && message.type === 'assistant' && message.message?.content) {
539
+ const content = message.message.content;
540
+ let textChunk = '';
541
+ if (typeof content === 'string') {
542
+ textChunk = content;
543
+ } else if (Array.isArray(content)) {
544
+ textChunk = content.filter(b => b.type === 'text').map(b => b.text).join('');
545
+ }
546
+ if (textChunk) {
547
+ if (state._roleplayAccumulated === undefined) {
548
+ state._roleplayAccumulated = '';
549
+ }
550
+ state._roleplayAccumulated += textChunk;
551
+
552
+ // Detect ROLE signal for current role tracking
553
+ const rpSession = rolePlaySessions.get(conversationId);
554
+ if (rpSession && rpSession._routeInitialized) {
555
+ const detectedRole = detectRoleSignal(textChunk);
556
+ if (detectedRole) {
557
+ const prevRole = rpSession.currentRole;
558
+ rpSession.currentRole = detectedRole;
559
+ if (rpSession.roleStates[detectedRole]) {
560
+ rpSession.roleStates[detectedRole].status = 'active';
561
+ }
562
+ if (prevRole && prevRole !== detectedRole && rpSession.roleStates[prevRole]) {
563
+ rpSession.roleStates[prevRole].status = 'idle';
564
+ }
565
+ console.log(`[RolePlay] Role switched: ${prevRole || 'none'} -> ${detectedRole}`);
566
+ }
567
+ }
568
+ }
569
+ }
570
+
437
571
  // 检测后台任务
438
572
  detectAndTrackBackgroundTask(conversationId, state, message);
439
573
 
574
+ // ★ RolePlay: attach role metadata to messages sent to frontend
575
+ if (state.rolePlayConfig) {
576
+ const rpSession = rolePlaySessions.get(conversationId);
577
+ if (rpSession && rpSession._routeInitialized) {
578
+ // Attach current role info to the message as metadata
579
+ const enrichedMessage = {
580
+ ...message,
581
+ _roleplay: {
582
+ role: rpSession.currentRole,
583
+ features: rpSession.features ? Array.from(rpSession.features.values()) : [],
584
+ round: rpSession.round
585
+ }
586
+ };
587
+ sendOutput(conversationId, enrichedMessage);
588
+ continue;
589
+ }
590
+ }
591
+
440
592
  sendOutput(conversationId, message);
441
593
  }
442
594
  } catch (error) {
package/conversation.js CHANGED
@@ -2,7 +2,7 @@ import ctx from './context.js';
2
2
  import { loadSessionHistory } from './history.js';
3
3
  import { startClaudeQuery } from './claude.js';
4
4
  import { crewSessions, loadCrewIndex } from './crew.js';
5
- import { rolePlaySessions, saveRolePlayIndex, removeRolePlaySession, loadRolePlayIndex, validateRolePlayConfig } from './roleplay.js';
5
+ import { rolePlaySessions, saveRolePlayIndex, removeRolePlaySession, loadRolePlayIndex, validateRolePlayConfig, initRolePlayRouteState } from './roleplay.js';
6
6
 
7
7
  // Restore persisted roleplay sessions on module load (agent startup)
8
8
  loadRolePlayIndex();
@@ -54,7 +54,17 @@ export async function sendConversationList() {
54
54
  // roleplay conversations are stored in ctx.conversations but also tracked in rolePlaySessions
55
55
  if (rolePlaySessions.has(id)) {
56
56
  entry.type = 'rolePlay';
57
- entry.rolePlayRoles = rolePlaySessions.get(id).roles;
57
+ const rpSession = rolePlaySessions.get(id);
58
+ entry.rolePlayRoles = rpSession.roles;
59
+ // Include route state if initialized
60
+ if (rpSession._routeInitialized) {
61
+ entry.rolePlayState = {
62
+ currentRole: rpSession.currentRole,
63
+ round: rpSession.round,
64
+ features: rpSession.features ? Array.from(rpSession.features.values()) : [],
65
+ waitingHuman: rpSession.waitingHuman || false
66
+ };
67
+ }
58
68
  }
59
69
  list.push(entry);
60
70
  }
@@ -161,7 +171,7 @@ export async function createConversation(msg) {
161
171
 
162
172
  // Register in rolePlaySessions for type inference in sendConversationList
163
173
  if (rolePlayConfig) {
164
- rolePlaySessions.set(conversationId, {
174
+ const rpSession = {
165
175
  roles: rolePlayConfig.roles,
166
176
  teamType: rolePlayConfig.teamType,
167
177
  language: rolePlayConfig.language,
@@ -169,7 +179,10 @@ export async function createConversation(msg) {
169
179
  createdAt: Date.now(),
170
180
  userId,
171
181
  username,
172
- });
182
+ };
183
+ rolePlaySessions.set(conversationId, rpSession);
184
+ // Initialize route state eagerly so it's ready when Claude starts
185
+ initRolePlayRouteState(rpSession, ctx.conversations.get(conversationId));
173
186
  saveRolePlayIndex();
174
187
  }
175
188
 
@@ -467,9 +480,29 @@ export async function handleUserInput(msg) {
467
480
 
468
481
  // 发送用户消息到输入流
469
482
  // Claude stream-json 模式支持在回复过程中接收新消息(写入 stdin)
483
+ let effectivePrompt = prompt;
484
+
485
+ // ★ RolePlay: if session was waiting for human input, clear the flag and
486
+ // wrap the user message with context about which role was asking
487
+ const rpSession = rolePlaySessions.get(conversationId);
488
+ if (rpSession && rpSession.waitingHuman && rpSession.waitingHumanContext) {
489
+ const { fromRole, message: requestMessage } = rpSession.waitingHumanContext;
490
+ const fromRoleConfig = rpSession.roles.find?.(r => r.name === fromRole) ||
491
+ (Array.isArray(rpSession.roles) ? rpSession.roles.find(r => r.name === fromRole) : null);
492
+ const fromLabel = fromRoleConfig
493
+ ? (fromRoleConfig.icon ? `${fromRoleConfig.icon} ${fromRoleConfig.displayName}` : fromRoleConfig.displayName)
494
+ : fromRole;
495
+
496
+ effectivePrompt = `人工回复(回应 ${fromLabel} 的请求: "${requestMessage}"):\n\n${prompt}`;
497
+
498
+ rpSession.waitingHuman = false;
499
+ rpSession.waitingHumanContext = null;
500
+ console.log(`[RolePlay] Human responded, resuming from ${fromRole}'s request`);
501
+ }
502
+
470
503
  const userMessage = {
471
504
  type: 'user',
472
- message: { role: 'user', content: prompt }
505
+ message: { role: 'user', content: effectivePrompt }
473
506
  };
474
507
 
475
508
  console.log(`[${conversationId}] Sending: ${prompt.substring(0, 100)}...`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.62",
3
+ "version": "0.1.63",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/roleplay.js CHANGED
@@ -1,13 +1,16 @@
1
1
  /**
2
2
  * Role Play — lightweight multi-role collaboration within a single conversation.
3
3
  *
4
- * Manages rolePlaySessions (in-memory + persisted to disk) and builds the
5
- * appendSystemPrompt that instructs Claude to role-play multiple characters.
4
+ * Manages rolePlaySessions (in-memory + persisted to disk), builds the
5
+ * appendSystemPrompt that instructs Claude to role-play multiple characters,
6
+ * and handles ROUTE protocol for Crew-style role switching within a single
7
+ * Claude conversation.
6
8
  */
7
9
 
8
10
  import { join } from 'path';
9
11
  import { homedir } from 'os';
10
12
  import { readFileSync, writeFileSync, existsSync, renameSync } from 'fs';
13
+ import { parseRoutes } from './crew/routing.js';
11
14
 
12
15
  const ROLEPLAY_INDEX_PATH = join(homedir(), '.claude', 'roleplay-sessions.json');
13
16
  // ★ backward compat: old filename before rename
@@ -23,7 +26,9 @@ export const rolePlaySessions = new Map();
23
26
  export function saveRolePlayIndex() {
24
27
  const data = [];
25
28
  for (const [id, session] of rolePlaySessions) {
26
- data.push({ id, ...session });
29
+ // Only persist core fields, skip runtime route state
30
+ const { _routeInitialized, currentRole, features, round, roleStates, waitingHuman, waitingHumanContext, ...core } = session;
31
+ data.push({ id, ...core });
27
32
  }
28
33
  try {
29
34
  writeFileSync(ROLEPLAY_INDEX_PATH, JSON.stringify(data, null, 2));
@@ -196,17 +201,37 @@ ${roleList}
196
201
 
197
202
  ## 角色切换规则
198
203
 
199
- 切换角色时,输出以下信号(必须独占一行,前后不能有其他内容):
204
+ ### 方式一:ROUTE 协议(推荐)
205
+
206
+ 当一个角色完成工作需要交给另一个角色时,使用 ROUTE 块:
207
+
208
+ \`\`\`
209
+ ---ROUTE---
210
+ to: {目标角色name}
211
+ summary: {交接内容摘要}
212
+ task: {任务ID,如 task-1}(可选)
213
+ taskTitle: {任务标题}(可选)
214
+ ---END_ROUTE---
215
+ \`\`\`
216
+
217
+ ROUTE 规则:
218
+ - 一次可以输出多个 ROUTE 块(例如同时发给 reviewer 和 tester)
219
+ - \`to\` 必须是有效的角色 name,或 \`human\` 表示需要用户输入
220
+ - \`summary\` 是交给目标角色的具体任务和上下文
221
+ - \`task\` / \`taskTitle\` 用于追踪 feature/任务(PM 分配任务时应填写)
222
+ - ROUTE 块必须在角色输出的末尾
223
+
224
+ ### 方式二:ROLE 信号(简单切换)
200
225
 
201
226
  ---ROLE: {角色name}---
202
227
 
203
- 切换后,你必须完全以该角色的视角和人格思考、说话和行动。不要混用其他角色的口吻。
228
+ 直接切换到目标角色继续工作,适用于简单的角色轮转。
204
229
 
205
- **重要**:
230
+ ### 通用规则
231
+
232
+ - 切换后,你必须完全以该角色的视角和人格思考、说话和行动
206
233
  - 第一条输出必须先切换到起始角色(通常是 PM)
207
- - 每次切换时先用 1-2 句话说明为什么要切换(作为上一个角色的收尾)
208
- - 切换后立即以新角色身份开始工作
209
- - 信号格式必须严格匹配:三个短横线 + ROLE: + 空格 + 角色name + 三个短横线
234
+ - 每次切换前留下交接信息(完成了什么、对下一角色的要求)
210
235
 
211
236
  ## 工作流程
212
237
 
@@ -243,17 +268,37 @@ ${roleList}
243
268
 
244
269
  ## Role Switching Rules
245
270
 
246
- When switching roles, output the following signal (must be on its own line, nothing else before or after):
271
+ ### Method 1: ROUTE Protocol (Recommended)
272
+
273
+ When a role finishes work and needs to hand off to another role, use a ROUTE block:
274
+
275
+ \`\`\`
276
+ ---ROUTE---
277
+ to: {target_role_name}
278
+ summary: {handoff content summary}
279
+ task: {task ID, e.g. task-1} (optional)
280
+ taskTitle: {task title} (optional)
281
+ ---END_ROUTE---
282
+ \`\`\`
283
+
284
+ ROUTE rules:
285
+ - You can output multiple ROUTE blocks at once (e.g., send to both reviewer and tester)
286
+ - \`to\` must be a valid role name, or \`human\` to request user input
287
+ - \`summary\` is the specific task and context for the target role
288
+ - \`task\` / \`taskTitle\` are for tracking features/tasks (PM should fill these when assigning)
289
+ - ROUTE blocks must be at the end of the role's output
290
+
291
+ ### Method 2: ROLE Signal (Simple Switch)
247
292
 
248
293
  ---ROLE: {role_name}---
249
294
 
250
- After switching, you must fully think, speak, and act from that role's perspective and personality. Do not mix tones from other roles.
295
+ Directly switch to the target role to continue working. Suitable for simple role rotation.
296
+
297
+ ### General Rules
251
298
 
252
- **Important**:
299
+ - After switching, you must fully think, speak, and act from that role's perspective
253
300
  - Your first output must switch to the starting role (usually PM)
254
- - Before each switch, briefly explain why you're switching (as closure for the current role)
255
- - After switching, immediately begin working as the new role
256
- - Signal format must strictly match: three hyphens + ROLE: + space + role_name + three hyphens
301
+ - Before each switch, leave handoff information (what was done, requirements for next role)
257
302
 
258
303
  ## Workflow
259
304
 
@@ -320,3 +365,169 @@ function buildDevWorkflow(roleNames, isZh) {
320
365
 
321
366
  return steps.join('\n');
322
367
  }
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // RolePlay ROUTE protocol support
371
+ // ---------------------------------------------------------------------------
372
+
373
+ // Re-export parseRoutes for use by claude.js and tests
374
+ export { parseRoutes } from './crew/routing.js';
375
+
376
+ /**
377
+ * Initialize RolePlay route state on a session.
378
+ * Called when a roleplay conversation is first created or resumed.
379
+ *
380
+ * @param {object} session - rolePlaySessions entry
381
+ * @param {object} convState - ctx.conversations entry
382
+ */
383
+ export function initRolePlayRouteState(session, convState) {
384
+ if (!session._routeInitialized) {
385
+ session.currentRole = null;
386
+ session.features = new Map();
387
+ session.round = 0;
388
+ session.roleStates = {};
389
+ session.waitingHuman = false;
390
+ session.waitingHumanContext = null;
391
+
392
+ // Initialize per-role states
393
+ for (const role of session.roles) {
394
+ session.roleStates[role.name] = {
395
+ currentTask: null,
396
+ status: 'idle'
397
+ };
398
+ }
399
+ session._routeInitialized = true;
400
+ }
401
+
402
+ // Also store accumulated text on convState for ROUTE detection during streaming
403
+ if (!convState._roleplayAccumulated) {
404
+ convState._roleplayAccumulated = '';
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Detect a ROLE signal in text: ---ROLE: xxx---
410
+ * Returns the role name if found at the end of accumulated text, null otherwise.
411
+ */
412
+ export function detectRoleSignal(text) {
413
+ const match = text.match(/---ROLE:\s*([a-zA-Z0-9_-]+)\s*---/);
414
+ return match ? match[1].toLowerCase() : null;
415
+ }
416
+
417
+ /**
418
+ * Process ROUTE blocks detected in a completed turn's output.
419
+ * Called from claude.js when a result message is received.
420
+ *
421
+ * Returns { routes, hasHumanRoute, continueRoles } for the caller to act on.
422
+ *
423
+ * @param {string} accumulatedText - full text output from the current turn
424
+ * @param {object} session - rolePlaySessions entry
425
+ * @returns {{ routes: Array, hasHumanRoute: boolean, continueRoles: Array<{to, prompt}> }}
426
+ */
427
+ export function processRolePlayRoutes(accumulatedText, session) {
428
+ const routes = parseRoutes(accumulatedText);
429
+ if (routes.length === 0) {
430
+ return { routes: [], hasHumanRoute: false, continueRoles: [] };
431
+ }
432
+
433
+ const roleNames = new Set(session.roles.map(r => r.name));
434
+ let hasHumanRoute = false;
435
+ const continueRoles = [];
436
+
437
+ for (const route of routes) {
438
+ const { to, summary, taskId, taskTitle } = route;
439
+
440
+ // Track features
441
+ if (taskId && taskTitle && !session.features.has(taskId)) {
442
+ session.features.set(taskId, { taskId, taskTitle, createdAt: Date.now() });
443
+ }
444
+
445
+ // Update source role state
446
+ if (session.currentRole && session.roleStates[session.currentRole]) {
447
+ session.roleStates[session.currentRole].status = 'idle';
448
+ }
449
+
450
+ if (to === 'human') {
451
+ hasHumanRoute = true;
452
+ session.waitingHuman = true;
453
+ session.waitingHumanContext = {
454
+ fromRole: session.currentRole,
455
+ reason: 'requested',
456
+ message: summary
457
+ };
458
+ } else if (roleNames.has(to)) {
459
+ // Update target role state
460
+ if (session.roleStates[to]) {
461
+ session.roleStates[to].status = 'active';
462
+ if (taskId) {
463
+ session.roleStates[to].currentTask = { taskId, taskTitle };
464
+ }
465
+ }
466
+
467
+ // Build prompt for the target role
468
+ const fromRole = session.currentRole || 'unknown';
469
+ const fromRoleConfig = session.roles.find(r => r.name === fromRole);
470
+ const fromLabel = fromRoleConfig
471
+ ? (fromRoleConfig.icon ? `${fromRoleConfig.icon} ${fromRoleConfig.displayName}` : fromRoleConfig.displayName)
472
+ : fromRole;
473
+
474
+ const targetRoleConfig = session.roles.find(r => r.name === to);
475
+ const targetClaudeMd = targetRoleConfig?.claudeMd || '';
476
+
477
+ let prompt = `来自 ${fromLabel} 的消息:\n${summary}\n\n`;
478
+ if (targetClaudeMd) {
479
+ prompt += `---\n<role-context>\n${targetClaudeMd}\n</role-context>\n\n`;
480
+ }
481
+ prompt += `你现在是 ${targetRoleConfig?.displayName || to}。请开始你的工作。完成后通过 ROUTE 块传递给下一个角色。`;
482
+
483
+ continueRoles.push({ to, prompt, taskId, taskTitle });
484
+ } else {
485
+ console.warn(`[RolePlay] Unknown route target: ${to}`);
486
+ }
487
+ }
488
+
489
+ // Increment round
490
+ session.round++;
491
+
492
+ return { routes, hasHumanRoute, continueRoles };
493
+ }
494
+
495
+ /**
496
+ * Build the route event message to send to the frontend via WebSocket.
497
+ *
498
+ * @param {string} conversationId
499
+ * @param {string} fromRole
500
+ * @param {{ to, summary, taskId, taskTitle }} route
501
+ * @returns {object} WebSocket message
502
+ */
503
+ export function buildRouteEventMessage(conversationId, fromRole, route) {
504
+ return {
505
+ type: 'roleplay_route',
506
+ conversationId,
507
+ from: fromRole,
508
+ to: route.to,
509
+ taskId: route.taskId || null,
510
+ taskTitle: route.taskTitle || null,
511
+ summary: route.summary || ''
512
+ };
513
+ }
514
+
515
+ /**
516
+ * Get the current RolePlay route state summary for frontend status updates.
517
+ *
518
+ * @param {string} conversationId
519
+ * @returns {object|null} Route state summary or null if not a roleplay session
520
+ */
521
+ export function getRolePlayRouteState(conversationId) {
522
+ const session = rolePlaySessions.get(conversationId);
523
+ if (!session || !session._routeInitialized) return null;
524
+
525
+ return {
526
+ currentRole: session.currentRole,
527
+ round: session.round,
528
+ features: session.features ? Array.from(session.features.values()) : [],
529
+ roleStates: session.roleStates || {},
530
+ waitingHuman: session.waitingHuman || false,
531
+ waitingHumanContext: session.waitingHumanContext || null
532
+ };
533
+ }