@yeaft/webchat-agent 0.1.62 → 0.1.64
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 +153 -1
- package/connection/message-router.js +5 -1
- package/conversation.js +82 -5
- package/package.json +1 -1
- package/roleplay.js +414 -18
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 {
|
|
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) {
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
createConversation, resumeConversation, deleteConversation,
|
|
13
13
|
handleRefreshConversation, handleCancelExecution,
|
|
14
14
|
handleUserInput, handleUpdateConversationSettings, handleAskUserAnswer,
|
|
15
|
-
sendConversationList
|
|
15
|
+
sendConversationList, handleCheckCrewContext
|
|
16
16
|
} from '../conversation.js';
|
|
17
17
|
import {
|
|
18
18
|
createCrewSession, handleCrewHumanInput, handleCrewControl,
|
|
@@ -66,6 +66,10 @@ export async function handleMessage(msg) {
|
|
|
66
66
|
await createConversation(msg);
|
|
67
67
|
break;
|
|
68
68
|
|
|
69
|
+
case 'check_crew_context':
|
|
70
|
+
handleCheckCrewContext(msg);
|
|
71
|
+
break;
|
|
72
|
+
|
|
69
73
|
case 'resume_conversation':
|
|
70
74
|
await resumeConversation(msg);
|
|
71
75
|
break;
|
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, loadCrewContext } 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
|
-
|
|
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
|
}
|
|
@@ -130,6 +140,15 @@ export async function createConversation(msg) {
|
|
|
130
140
|
rolePlayConfig = result.config;
|
|
131
141
|
}
|
|
132
142
|
|
|
143
|
+
// Load .crew context if rolePlay and projectDir contains .crew directory
|
|
144
|
+
if (rolePlayConfig) {
|
|
145
|
+
const crewContext = loadCrewContext(effectiveWorkDir);
|
|
146
|
+
if (crewContext) {
|
|
147
|
+
rolePlayConfig.crewContext = crewContext;
|
|
148
|
+
console.log(` RolePlay: loaded .crew context (${crewContext.roles.length} roles, ${crewContext.features.length} features)`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
133
152
|
console.log(`Creating conversation: ${conversationId} in ${effectiveWorkDir} (lazy start)`);
|
|
134
153
|
if (username) console.log(` User: ${username} (${userId})`);
|
|
135
154
|
if (rolePlayConfig) console.log(` RolePlay: teamType=${rolePlayConfig.teamType}, roles=${rolePlayConfig.roles?.length}`);
|
|
@@ -161,7 +180,7 @@ export async function createConversation(msg) {
|
|
|
161
180
|
|
|
162
181
|
// Register in rolePlaySessions for type inference in sendConversationList
|
|
163
182
|
if (rolePlayConfig) {
|
|
164
|
-
|
|
183
|
+
const rpSession = {
|
|
165
184
|
roles: rolePlayConfig.roles,
|
|
166
185
|
teamType: rolePlayConfig.teamType,
|
|
167
186
|
language: rolePlayConfig.language,
|
|
@@ -169,7 +188,10 @@ export async function createConversation(msg) {
|
|
|
169
188
|
createdAt: Date.now(),
|
|
170
189
|
userId,
|
|
171
190
|
username,
|
|
172
|
-
}
|
|
191
|
+
};
|
|
192
|
+
rolePlaySessions.set(conversationId, rpSession);
|
|
193
|
+
// Initialize route state eagerly so it's ready when Claude starts
|
|
194
|
+
initRolePlayRouteState(rpSession, ctx.conversations.get(conversationId));
|
|
173
195
|
saveRolePlayIndex();
|
|
174
196
|
}
|
|
175
197
|
|
|
@@ -467,9 +489,29 @@ export async function handleUserInput(msg) {
|
|
|
467
489
|
|
|
468
490
|
// 发送用户消息到输入流
|
|
469
491
|
// Claude stream-json 模式支持在回复过程中接收新消息(写入 stdin)
|
|
492
|
+
let effectivePrompt = prompt;
|
|
493
|
+
|
|
494
|
+
// ★ RolePlay: if session was waiting for human input, clear the flag and
|
|
495
|
+
// wrap the user message with context about which role was asking
|
|
496
|
+
const rpSession = rolePlaySessions.get(conversationId);
|
|
497
|
+
if (rpSession && rpSession.waitingHuman && rpSession.waitingHumanContext) {
|
|
498
|
+
const { fromRole, message: requestMessage } = rpSession.waitingHumanContext;
|
|
499
|
+
const fromRoleConfig = rpSession.roles.find?.(r => r.name === fromRole) ||
|
|
500
|
+
(Array.isArray(rpSession.roles) ? rpSession.roles.find(r => r.name === fromRole) : null);
|
|
501
|
+
const fromLabel = fromRoleConfig
|
|
502
|
+
? (fromRoleConfig.icon ? `${fromRoleConfig.icon} ${fromRoleConfig.displayName}` : fromRoleConfig.displayName)
|
|
503
|
+
: fromRole;
|
|
504
|
+
|
|
505
|
+
effectivePrompt = `人工回复(回应 ${fromLabel} 的请求: "${requestMessage}"):\n\n${prompt}`;
|
|
506
|
+
|
|
507
|
+
rpSession.waitingHuman = false;
|
|
508
|
+
rpSession.waitingHumanContext = null;
|
|
509
|
+
console.log(`[RolePlay] Human responded, resuming from ${fromRole}'s request`);
|
|
510
|
+
}
|
|
511
|
+
|
|
470
512
|
const userMessage = {
|
|
471
513
|
type: 'user',
|
|
472
|
-
message: { role: 'user', content:
|
|
514
|
+
message: { role: 'user', content: effectivePrompt }
|
|
473
515
|
};
|
|
474
516
|
|
|
475
517
|
console.log(`[${conversationId}] Sending: ${prompt.substring(0, 100)}...`);
|
|
@@ -556,3 +598,38 @@ export function handleAskUserAnswer(msg) {
|
|
|
556
598
|
console.log(`[AskUser] No pending question for requestId: ${msg.requestId}`);
|
|
557
599
|
}
|
|
558
600
|
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Handle check_crew_context request — check if a directory has .crew context
|
|
604
|
+
* and return role/context info for RolePlay auto-import.
|
|
605
|
+
*/
|
|
606
|
+
export function handleCheckCrewContext(msg) {
|
|
607
|
+
const { projectDir, requestId } = msg;
|
|
608
|
+
if (!projectDir) {
|
|
609
|
+
ctx.sendToServer({ type: 'crew_context_result', requestId, found: false });
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const crewContext = loadCrewContext(projectDir);
|
|
613
|
+
if (!crewContext) {
|
|
614
|
+
ctx.sendToServer({ type: 'crew_context_result', requestId, found: false });
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
// Return a safe subset for the frontend (no full claudeMd content, just metadata)
|
|
618
|
+
ctx.sendToServer({
|
|
619
|
+
type: 'crew_context_result',
|
|
620
|
+
requestId,
|
|
621
|
+
found: true,
|
|
622
|
+
roles: crewContext.roles.map(r => ({
|
|
623
|
+
name: r.name,
|
|
624
|
+
displayName: r.displayName,
|
|
625
|
+
icon: r.icon,
|
|
626
|
+
description: r.description,
|
|
627
|
+
roleType: r.roleType,
|
|
628
|
+
isDecisionMaker: r.isDecisionMaker,
|
|
629
|
+
hasClaudeMd: !!(r.claudeMd && r.claudeMd.length > 0),
|
|
630
|
+
})),
|
|
631
|
+
teamType: crewContext.teamType,
|
|
632
|
+
language: crewContext.language,
|
|
633
|
+
featureCount: crewContext.features.length,
|
|
634
|
+
});
|
|
635
|
+
}
|
package/package.json
CHANGED
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)
|
|
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
|
-
import { readFileSync, writeFileSync, existsSync, renameSync } from 'fs';
|
|
12
|
+
import { readFileSync, writeFileSync, existsSync, renameSync, readdirSync } 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
|
-
|
|
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));
|
|
@@ -65,6 +70,147 @@ export function removeRolePlaySession(conversationId) {
|
|
|
65
70
|
saveRolePlayIndex();
|
|
66
71
|
}
|
|
67
72
|
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// .crew context import
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Read a file if it exists, otherwise return null.
|
|
79
|
+
* @param {string} filePath
|
|
80
|
+
* @returns {string|null}
|
|
81
|
+
*/
|
|
82
|
+
function readFileOrNull(filePath) {
|
|
83
|
+
try {
|
|
84
|
+
if (!existsSync(filePath)) return null;
|
|
85
|
+
return readFileSync(filePath, 'utf-8');
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Load .crew context from a project directory.
|
|
93
|
+
* Returns null if .crew/ doesn't exist.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} projectDir - absolute path to project root
|
|
96
|
+
* @returns {{ sharedClaudeMd: string, roles: Array, kanban: string, features: Array, teamType: string, language: string } | null}
|
|
97
|
+
*/
|
|
98
|
+
export function loadCrewContext(projectDir) {
|
|
99
|
+
const crewDir = join(projectDir, '.crew');
|
|
100
|
+
if (!existsSync(crewDir)) return null;
|
|
101
|
+
|
|
102
|
+
// 1. Shared CLAUDE.md
|
|
103
|
+
const sharedClaudeMd = readFileOrNull(join(crewDir, 'CLAUDE.md')) || '';
|
|
104
|
+
|
|
105
|
+
// 2. session.json → roles, teamType, language, features
|
|
106
|
+
let sessionRoles = [];
|
|
107
|
+
let teamType = 'dev';
|
|
108
|
+
let language = 'zh-CN';
|
|
109
|
+
let sessionFeatures = [];
|
|
110
|
+
const sessionPath = join(crewDir, 'session.json');
|
|
111
|
+
const sessionJson = readFileOrNull(sessionPath);
|
|
112
|
+
if (sessionJson) {
|
|
113
|
+
try {
|
|
114
|
+
const session = JSON.parse(sessionJson);
|
|
115
|
+
if (Array.isArray(session.roles)) {
|
|
116
|
+
sessionRoles = session.roles;
|
|
117
|
+
}
|
|
118
|
+
if (session.teamType) teamType = session.teamType;
|
|
119
|
+
if (session.language) language = session.language;
|
|
120
|
+
if (Array.isArray(session.features)) {
|
|
121
|
+
sessionFeatures = session.features;
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
// Invalid JSON — ignore
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 3. Per-role CLAUDE.md from .crew/roles/*/CLAUDE.md
|
|
129
|
+
const roleClaudes = {};
|
|
130
|
+
const rolesDir = join(crewDir, 'roles');
|
|
131
|
+
if (existsSync(rolesDir)) {
|
|
132
|
+
try {
|
|
133
|
+
const roleDirs = readdirSync(rolesDir, { withFileTypes: true })
|
|
134
|
+
.filter(d => d.isDirectory())
|
|
135
|
+
.map(d => d.name);
|
|
136
|
+
for (const dirName of roleDirs) {
|
|
137
|
+
const md = readFileOrNull(join(rolesDir, dirName, 'CLAUDE.md'));
|
|
138
|
+
if (md) roleClaudes[dirName] = md;
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
// Permission error or similar — ignore
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 4. Merge roles: deduplicate by roleType, attach claudeMd
|
|
146
|
+
const roles = deduplicateRoles(sessionRoles, roleClaudes);
|
|
147
|
+
|
|
148
|
+
// 5. Kanban
|
|
149
|
+
const kanban = readFileOrNull(join(crewDir, 'context', 'kanban.md')) || '';
|
|
150
|
+
|
|
151
|
+
// 6. Feature files from context/features/*.md
|
|
152
|
+
const features = [];
|
|
153
|
+
const featuresDir = join(crewDir, 'context', 'features');
|
|
154
|
+
if (existsSync(featuresDir)) {
|
|
155
|
+
try {
|
|
156
|
+
const files = readdirSync(featuresDir)
|
|
157
|
+
.filter(f => f.endsWith('.md') && f !== 'index.md')
|
|
158
|
+
.sort();
|
|
159
|
+
for (const f of files) {
|
|
160
|
+
const content = readFileOrNull(join(featuresDir, f));
|
|
161
|
+
if (content) {
|
|
162
|
+
features.push({ name: f.replace('.md', ''), content });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} catch {
|
|
166
|
+
// ignore
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { sharedClaudeMd, roles, kanban, features, teamType, language, sessionFeatures };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Deduplicate Crew roles by roleType.
|
|
175
|
+
* Crew may have dev-1, dev-2, dev-3 — collapse to a single "dev" for RolePlay.
|
|
176
|
+
* Attaches per-role CLAUDE.md content.
|
|
177
|
+
*/
|
|
178
|
+
function deduplicateRoles(sessionRoles, roleClaudes) {
|
|
179
|
+
const byType = new Map(); // roleType -> first role seen
|
|
180
|
+
const merged = [];
|
|
181
|
+
|
|
182
|
+
for (const r of sessionRoles) {
|
|
183
|
+
const type = r.roleType || r.name;
|
|
184
|
+
const claudeMd = roleClaudes[r.name] || '';
|
|
185
|
+
|
|
186
|
+
if (byType.has(type)) {
|
|
187
|
+
// Already have this roleType — skip duplicate instance
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
byType.set(type, true);
|
|
192
|
+
|
|
193
|
+
// Use roleType as the RolePlay name (e.g. "developer" instead of "dev-1")
|
|
194
|
+
// But keep "pm" and "designer" as-is since they're typically single-instance
|
|
195
|
+
const name = type;
|
|
196
|
+
// Strip instance suffix from displayName (e.g. "开发者-托瓦兹-1" → "开发者-托瓦兹")
|
|
197
|
+
let displayName = r.displayName || name;
|
|
198
|
+
displayName = displayName.replace(/-\d+$/, '');
|
|
199
|
+
|
|
200
|
+
merged.push({
|
|
201
|
+
name,
|
|
202
|
+
displayName,
|
|
203
|
+
icon: r.icon || '',
|
|
204
|
+
description: r.description || '',
|
|
205
|
+
claudeMd: claudeMd.substring(0, MAX_CLAUDE_MD_LEN),
|
|
206
|
+
roleType: type,
|
|
207
|
+
isDecisionMaker: !!r.isDecisionMaker,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return merged;
|
|
212
|
+
}
|
|
213
|
+
|
|
68
214
|
// ---------------------------------------------------------------------------
|
|
69
215
|
// Input validation
|
|
70
216
|
// ---------------------------------------------------------------------------
|
|
@@ -160,11 +306,11 @@ export function validateRolePlayConfig(config) {
|
|
|
160
306
|
* Build the appendSystemPrompt that tells Claude about the role play roles
|
|
161
307
|
* and how to switch between them.
|
|
162
308
|
*
|
|
163
|
-
* @param {{ roles: Array, teamType: string, language: string }} config
|
|
309
|
+
* @param {{ roles: Array, teamType: string, language: string, crewContext?: object }} config
|
|
164
310
|
* @returns {string}
|
|
165
311
|
*/
|
|
166
312
|
export function buildRolePlaySystemPrompt(config) {
|
|
167
|
-
const { roles, teamType, language } = config;
|
|
313
|
+
const { roles, teamType, language, crewContext } = config;
|
|
168
314
|
const isZh = language === 'zh-CN';
|
|
169
315
|
|
|
170
316
|
// Build role list
|
|
@@ -181,6 +327,14 @@ export function buildRolePlaySystemPrompt(config) {
|
|
|
181
327
|
? buildZhPrompt(roleList, workflow)
|
|
182
328
|
: buildEnPrompt(roleList, workflow);
|
|
183
329
|
|
|
330
|
+
// Append .crew context if available
|
|
331
|
+
if (crewContext) {
|
|
332
|
+
const contextBlock = buildCrewContextBlock(crewContext, isZh);
|
|
333
|
+
if (contextBlock) {
|
|
334
|
+
return (prompt + '\n\n' + contextBlock).trim();
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
184
338
|
return prompt.trim();
|
|
185
339
|
}
|
|
186
340
|
|
|
@@ -196,17 +350,37 @@ ${roleList}
|
|
|
196
350
|
|
|
197
351
|
## 角色切换规则
|
|
198
352
|
|
|
199
|
-
|
|
353
|
+
### 方式一:ROUTE 协议(推荐)
|
|
354
|
+
|
|
355
|
+
当一个角色完成工作需要交给另一个角色时,使用 ROUTE 块:
|
|
356
|
+
|
|
357
|
+
\`\`\`
|
|
358
|
+
---ROUTE---
|
|
359
|
+
to: {目标角色name}
|
|
360
|
+
summary: {交接内容摘要}
|
|
361
|
+
task: {任务ID,如 task-1}(可选)
|
|
362
|
+
taskTitle: {任务标题}(可选)
|
|
363
|
+
---END_ROUTE---
|
|
364
|
+
\`\`\`
|
|
365
|
+
|
|
366
|
+
ROUTE 规则:
|
|
367
|
+
- 一次可以输出多个 ROUTE 块(例如同时发给 reviewer 和 tester)
|
|
368
|
+
- \`to\` 必须是有效的角色 name,或 \`human\` 表示需要用户输入
|
|
369
|
+
- \`summary\` 是交给目标角色的具体任务和上下文
|
|
370
|
+
- \`task\` / \`taskTitle\` 用于追踪 feature/任务(PM 分配任务时应填写)
|
|
371
|
+
- ROUTE 块必须在角色输出的末尾
|
|
372
|
+
|
|
373
|
+
### 方式二:ROLE 信号(简单切换)
|
|
200
374
|
|
|
201
375
|
---ROLE: {角色name}---
|
|
202
376
|
|
|
203
|
-
|
|
377
|
+
直接切换到目标角色继续工作,适用于简单的角色轮转。
|
|
204
378
|
|
|
205
|
-
|
|
379
|
+
### 通用规则
|
|
380
|
+
|
|
381
|
+
- 切换后,你必须完全以该角色的视角和人格思考、说话和行动
|
|
206
382
|
- 第一条输出必须先切换到起始角色(通常是 PM)
|
|
207
|
-
-
|
|
208
|
-
- 切换后立即以新角色身份开始工作
|
|
209
|
-
- 信号格式必须严格匹配:三个短横线 + ROLE: + 空格 + 角色name + 三个短横线
|
|
383
|
+
- 每次切换前留下交接信息(完成了什么、对下一角色的要求)
|
|
210
384
|
|
|
211
385
|
## 工作流程
|
|
212
386
|
|
|
@@ -243,17 +417,37 @@ ${roleList}
|
|
|
243
417
|
|
|
244
418
|
## Role Switching Rules
|
|
245
419
|
|
|
246
|
-
|
|
420
|
+
### Method 1: ROUTE Protocol (Recommended)
|
|
421
|
+
|
|
422
|
+
When a role finishes work and needs to hand off to another role, use a ROUTE block:
|
|
423
|
+
|
|
424
|
+
\`\`\`
|
|
425
|
+
---ROUTE---
|
|
426
|
+
to: {target_role_name}
|
|
427
|
+
summary: {handoff content summary}
|
|
428
|
+
task: {task ID, e.g. task-1} (optional)
|
|
429
|
+
taskTitle: {task title} (optional)
|
|
430
|
+
---END_ROUTE---
|
|
431
|
+
\`\`\`
|
|
432
|
+
|
|
433
|
+
ROUTE rules:
|
|
434
|
+
- You can output multiple ROUTE blocks at once (e.g., send to both reviewer and tester)
|
|
435
|
+
- \`to\` must be a valid role name, or \`human\` to request user input
|
|
436
|
+
- \`summary\` is the specific task and context for the target role
|
|
437
|
+
- \`task\` / \`taskTitle\` are for tracking features/tasks (PM should fill these when assigning)
|
|
438
|
+
- ROUTE blocks must be at the end of the role's output
|
|
439
|
+
|
|
440
|
+
### Method 2: ROLE Signal (Simple Switch)
|
|
247
441
|
|
|
248
442
|
---ROLE: {role_name}---
|
|
249
443
|
|
|
250
|
-
|
|
444
|
+
Directly switch to the target role to continue working. Suitable for simple role rotation.
|
|
251
445
|
|
|
252
|
-
|
|
446
|
+
### General Rules
|
|
447
|
+
|
|
448
|
+
- After switching, you must fully think, speak, and act from that role's perspective
|
|
253
449
|
- Your first output must switch to the starting role (usually PM)
|
|
254
|
-
- Before each switch,
|
|
255
|
-
- After switching, immediately begin working as the new role
|
|
256
|
-
- Signal format must strictly match: three hyphens + ROLE: + space + role_name + three hyphens
|
|
450
|
+
- Before each switch, leave handoff information (what was done, requirements for next role)
|
|
257
451
|
|
|
258
452
|
## Workflow
|
|
259
453
|
|
|
@@ -320,3 +514,205 @@ function buildDevWorkflow(roleNames, isZh) {
|
|
|
320
514
|
|
|
321
515
|
return steps.join('\n');
|
|
322
516
|
}
|
|
517
|
+
|
|
518
|
+
// ---------------------------------------------------------------------------
|
|
519
|
+
// RolePlay ROUTE protocol support
|
|
520
|
+
// ---------------------------------------------------------------------------
|
|
521
|
+
|
|
522
|
+
// Re-export parseRoutes for use by claude.js and tests
|
|
523
|
+
export { parseRoutes } from './crew/routing.js';
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Initialize RolePlay route state on a session.
|
|
527
|
+
* Called when a roleplay conversation is first created or resumed.
|
|
528
|
+
*
|
|
529
|
+
* @param {object} session - rolePlaySessions entry
|
|
530
|
+
* @param {object} convState - ctx.conversations entry
|
|
531
|
+
*/
|
|
532
|
+
export function initRolePlayRouteState(session, convState) {
|
|
533
|
+
if (!session._routeInitialized) {
|
|
534
|
+
session.currentRole = null;
|
|
535
|
+
session.features = new Map();
|
|
536
|
+
session.round = 0;
|
|
537
|
+
session.roleStates = {};
|
|
538
|
+
session.waitingHuman = false;
|
|
539
|
+
session.waitingHumanContext = null;
|
|
540
|
+
|
|
541
|
+
// Initialize per-role states
|
|
542
|
+
for (const role of session.roles) {
|
|
543
|
+
session.roleStates[role.name] = {
|
|
544
|
+
currentTask: null,
|
|
545
|
+
status: 'idle'
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
session._routeInitialized = true;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Also store accumulated text on convState for ROUTE detection during streaming
|
|
552
|
+
if (!convState._roleplayAccumulated) {
|
|
553
|
+
convState._roleplayAccumulated = '';
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Detect a ROLE signal in text: ---ROLE: xxx---
|
|
559
|
+
* Returns the role name if found at the end of accumulated text, null otherwise.
|
|
560
|
+
*/
|
|
561
|
+
export function detectRoleSignal(text) {
|
|
562
|
+
const match = text.match(/---ROLE:\s*([a-zA-Z0-9_-]+)\s*---/);
|
|
563
|
+
return match ? match[1].toLowerCase() : null;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Process ROUTE blocks detected in a completed turn's output.
|
|
568
|
+
* Called from claude.js when a result message is received.
|
|
569
|
+
*
|
|
570
|
+
* Returns { routes, hasHumanRoute, continueRoles } for the caller to act on.
|
|
571
|
+
*
|
|
572
|
+
* @param {string} accumulatedText - full text output from the current turn
|
|
573
|
+
* @param {object} session - rolePlaySessions entry
|
|
574
|
+
* @returns {{ routes: Array, hasHumanRoute: boolean, continueRoles: Array<{to, prompt}> }}
|
|
575
|
+
*/
|
|
576
|
+
export function processRolePlayRoutes(accumulatedText, session) {
|
|
577
|
+
const routes = parseRoutes(accumulatedText);
|
|
578
|
+
if (routes.length === 0) {
|
|
579
|
+
return { routes: [], hasHumanRoute: false, continueRoles: [] };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const roleNames = new Set(session.roles.map(r => r.name));
|
|
583
|
+
let hasHumanRoute = false;
|
|
584
|
+
const continueRoles = [];
|
|
585
|
+
|
|
586
|
+
for (const route of routes) {
|
|
587
|
+
const { to, summary, taskId, taskTitle } = route;
|
|
588
|
+
|
|
589
|
+
// Track features
|
|
590
|
+
if (taskId && taskTitle && !session.features.has(taskId)) {
|
|
591
|
+
session.features.set(taskId, { taskId, taskTitle, createdAt: Date.now() });
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Update source role state
|
|
595
|
+
if (session.currentRole && session.roleStates[session.currentRole]) {
|
|
596
|
+
session.roleStates[session.currentRole].status = 'idle';
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (to === 'human') {
|
|
600
|
+
hasHumanRoute = true;
|
|
601
|
+
session.waitingHuman = true;
|
|
602
|
+
session.waitingHumanContext = {
|
|
603
|
+
fromRole: session.currentRole,
|
|
604
|
+
reason: 'requested',
|
|
605
|
+
message: summary
|
|
606
|
+
};
|
|
607
|
+
} else if (roleNames.has(to)) {
|
|
608
|
+
// Update target role state
|
|
609
|
+
if (session.roleStates[to]) {
|
|
610
|
+
session.roleStates[to].status = 'active';
|
|
611
|
+
if (taskId) {
|
|
612
|
+
session.roleStates[to].currentTask = { taskId, taskTitle };
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Build prompt for the target role
|
|
617
|
+
const fromRole = session.currentRole || 'unknown';
|
|
618
|
+
const fromRoleConfig = session.roles.find(r => r.name === fromRole);
|
|
619
|
+
const fromLabel = fromRoleConfig
|
|
620
|
+
? (fromRoleConfig.icon ? `${fromRoleConfig.icon} ${fromRoleConfig.displayName}` : fromRoleConfig.displayName)
|
|
621
|
+
: fromRole;
|
|
622
|
+
|
|
623
|
+
const targetRoleConfig = session.roles.find(r => r.name === to);
|
|
624
|
+
const targetClaudeMd = targetRoleConfig?.claudeMd || '';
|
|
625
|
+
|
|
626
|
+
let prompt = `来自 ${fromLabel} 的消息:\n${summary}\n\n`;
|
|
627
|
+
if (targetClaudeMd) {
|
|
628
|
+
prompt += `---\n<role-context>\n${targetClaudeMd}\n</role-context>\n\n`;
|
|
629
|
+
}
|
|
630
|
+
prompt += `你现在是 ${targetRoleConfig?.displayName || to}。请开始你的工作。完成后通过 ROUTE 块传递给下一个角色。`;
|
|
631
|
+
|
|
632
|
+
continueRoles.push({ to, prompt, taskId, taskTitle });
|
|
633
|
+
} else {
|
|
634
|
+
console.warn(`[RolePlay] Unknown route target: ${to}`);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Increment round
|
|
639
|
+
session.round++;
|
|
640
|
+
|
|
641
|
+
return { routes, hasHumanRoute, continueRoles };
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Build the route event message to send to the frontend via WebSocket.
|
|
646
|
+
*
|
|
647
|
+
* @param {string} conversationId
|
|
648
|
+
* @param {string} fromRole
|
|
649
|
+
* @param {{ to, summary, taskId, taskTitle }} route
|
|
650
|
+
* @returns {object} WebSocket message
|
|
651
|
+
*/
|
|
652
|
+
export function buildRouteEventMessage(conversationId, fromRole, route) {
|
|
653
|
+
return {
|
|
654
|
+
type: 'roleplay_route',
|
|
655
|
+
conversationId,
|
|
656
|
+
from: fromRole,
|
|
657
|
+
to: route.to,
|
|
658
|
+
taskId: route.taskId || null,
|
|
659
|
+
taskTitle: route.taskTitle || null,
|
|
660
|
+
summary: route.summary || ''
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Get the current RolePlay route state summary for frontend status updates.
|
|
666
|
+
*
|
|
667
|
+
* @param {string} conversationId
|
|
668
|
+
* @returns {object|null} Route state summary or null if not a roleplay session
|
|
669
|
+
*/
|
|
670
|
+
export function getRolePlayRouteState(conversationId) {
|
|
671
|
+
const session = rolePlaySessions.get(conversationId);
|
|
672
|
+
if (!session || !session._routeInitialized) return null;
|
|
673
|
+
|
|
674
|
+
return {
|
|
675
|
+
currentRole: session.currentRole,
|
|
676
|
+
round: session.round,
|
|
677
|
+
features: session.features ? Array.from(session.features.values()) : [],
|
|
678
|
+
roleStates: session.roleStates || {},
|
|
679
|
+
waitingHuman: session.waitingHuman || false,
|
|
680
|
+
waitingHumanContext: session.waitingHumanContext || null
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// ---------------------------------------------------------------------------
|
|
685
|
+
// .crew context block for system prompt
|
|
686
|
+
// ---------------------------------------------------------------------------
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Build the .crew context block to append to the system prompt.
|
|
690
|
+
* Includes shared instructions, kanban, and feature history.
|
|
691
|
+
*/
|
|
692
|
+
function buildCrewContextBlock(crewContext, isZh) {
|
|
693
|
+
const sections = [];
|
|
694
|
+
|
|
695
|
+
if (crewContext.sharedClaudeMd) {
|
|
696
|
+
const header = isZh ? '## 项目共享指令(来自 .crew)' : '## Shared Project Instructions (from .crew)';
|
|
697
|
+
sections.push(`${header}\n\n${crewContext.sharedClaudeMd}`);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (crewContext.kanban) {
|
|
701
|
+
const header = isZh ? '## 当前任务看板' : '## Current Task Board';
|
|
702
|
+
sections.push(`${header}\n\n${crewContext.kanban}`);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (crewContext.features && crewContext.features.length > 0) {
|
|
706
|
+
const header = isZh ? '## 历史工作记录' : '## Work History';
|
|
707
|
+
// Only include the last few features to avoid blowing up context
|
|
708
|
+
const recentFeatures = crewContext.features.slice(-5);
|
|
709
|
+
const featureTexts = recentFeatures.map(f => {
|
|
710
|
+
// Truncate each feature to keep total size reasonable
|
|
711
|
+
const content = f.content.length > 2000 ? f.content.substring(0, 2000) + '\n...(truncated)' : f.content;
|
|
712
|
+
return `### ${f.name}\n${content}`;
|
|
713
|
+
}).join('\n\n');
|
|
714
|
+
sections.push(`${header}\n\n${featureTexts}`);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
return sections.join('\n\n');
|
|
718
|
+
}
|