@yeaft/webchat-agent 0.1.124 → 0.1.125

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/roleplay.js DELETED
@@ -1,1061 +0,0 @@
1
- /**
2
- * Role Play — lightweight multi-role collaboration within a single conversation.
3
- *
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.
8
- */
9
-
10
- import { join } from 'path';
11
- import { homedir } from 'os';
12
- import { readFileSync, writeFileSync, existsSync, renameSync, readdirSync, statSync } from 'fs';
13
- import { promises as fsp } from 'fs';
14
- import { parseRoutes } from './crew/routing.js';
15
-
16
- const ROLEPLAY_INDEX_PATH = join(homedir(), '.claude', 'roleplay-sessions.json');
17
- // ★ backward compat: old filename before rename
18
- const LEGACY_INDEX_PATH = join(homedir(), '.claude', 'vcrew-sessions.json');
19
-
20
- // In-memory map: conversationId -> { roles, teamType, language, projectDir, createdAt, userId, username }
21
- export const rolePlaySessions = new Map();
22
-
23
- // ---------------------------------------------------------------------------
24
- // Persistence
25
- // ---------------------------------------------------------------------------
26
-
27
- export function saveRolePlayIndex() {
28
- const data = [];
29
- for (const [id, session] of rolePlaySessions) {
30
- // Only persist core fields, skip runtime route state and mtime snapshots
31
- const { _routeInitialized, _crewContextMtimes, currentRole, features, round, roleStates, waitingHuman, waitingHumanContext, ...core } = session;
32
- data.push({ id, ...core });
33
- }
34
- try {
35
- writeFileSync(ROLEPLAY_INDEX_PATH, JSON.stringify(data, null, 2));
36
- } catch (e) {
37
- console.warn('[roleplay] Failed to save index:', e.message);
38
- }
39
- }
40
-
41
- export function loadRolePlayIndex() {
42
- let indexPath = ROLEPLAY_INDEX_PATH;
43
-
44
- // ★ backward compat: migrate old vcrew-sessions.json → roleplay-sessions.json
45
- if (!existsSync(indexPath) && existsSync(LEGACY_INDEX_PATH)) {
46
- try {
47
- renameSync(LEGACY_INDEX_PATH, indexPath);
48
- console.log('[roleplay] Migrated vcrew-sessions.json → roleplay-sessions.json');
49
- } catch (e) {
50
- // rename failed (e.g. permissions), fall back to reading old file directly
51
- console.warn('[roleplay] Could not rename legacy index, reading in-place:', e.message);
52
- indexPath = LEGACY_INDEX_PATH;
53
- }
54
- }
55
-
56
- if (!existsSync(indexPath)) return;
57
- try {
58
- const data = JSON.parse(readFileSync(indexPath, 'utf-8'));
59
- for (const entry of data) {
60
- const { id, ...session } = entry;
61
- rolePlaySessions.set(id, session);
62
- }
63
- console.log(`[roleplay] Loaded ${rolePlaySessions.size} sessions from index`);
64
- } catch (e) {
65
- console.warn('[roleplay] Failed to load index:', e.message);
66
- }
67
- }
68
-
69
- export function removeRolePlaySession(conversationId) {
70
- rolePlaySessions.delete(conversationId);
71
- saveRolePlayIndex();
72
- }
73
-
74
- // ---------------------------------------------------------------------------
75
- // .crew context import
76
- // ---------------------------------------------------------------------------
77
-
78
- /**
79
- * Read a file if it exists, otherwise return null.
80
- * @param {string} filePath
81
- * @returns {string|null}
82
- */
83
- function readFileOrNull(filePath) {
84
- try {
85
- if (!existsSync(filePath)) return null;
86
- return readFileSync(filePath, 'utf-8');
87
- } catch {
88
- return null;
89
- }
90
- }
91
-
92
- /**
93
- * Load .crew context from a project directory.
94
- * Returns null if .crew/ doesn't exist.
95
- *
96
- * @param {string} projectDir - absolute path to project root
97
- * @returns {{ sharedClaudeMd: string, roles: Array, kanban: string, features: Array, teamType: string, language: string } | null}
98
- */
99
- export function loadCrewContext(projectDir) {
100
- const crewDir = join(projectDir, '.crew');
101
- if (!existsSync(crewDir)) return null;
102
-
103
- // 1. Shared CLAUDE.md
104
- const sharedClaudeMd = readFileOrNull(join(crewDir, 'CLAUDE.md')) || '';
105
-
106
- // 2. session.json → roles, teamType, language, features
107
- let sessionRoles = [];
108
- let teamType = 'dev';
109
- let language = 'zh-CN';
110
- let sessionFeatures = [];
111
- const sessionPath = join(crewDir, 'session.json');
112
- const sessionJson = readFileOrNull(sessionPath);
113
- if (sessionJson) {
114
- try {
115
- const session = JSON.parse(sessionJson);
116
- if (Array.isArray(session.roles)) {
117
- sessionRoles = session.roles;
118
- }
119
- if (session.teamType) teamType = session.teamType;
120
- if (session.language) language = session.language;
121
- if (Array.isArray(session.features)) {
122
- sessionFeatures = session.features;
123
- }
124
- } catch {
125
- // Invalid JSON — ignore
126
- }
127
- }
128
-
129
- // 3. Per-role CLAUDE.md from .crew/roles/*/CLAUDE.md
130
- const roleClaudes = {};
131
- const rolesDir = join(crewDir, 'roles');
132
- if (existsSync(rolesDir)) {
133
- try {
134
- const roleDirs = readdirSync(rolesDir, { withFileTypes: true })
135
- .filter(d => d.isDirectory())
136
- .map(d => d.name);
137
- for (const dirName of roleDirs) {
138
- const md = readFileOrNull(join(rolesDir, dirName, 'CLAUDE.md'));
139
- if (md) roleClaudes[dirName] = md;
140
- }
141
- } catch {
142
- // Permission error or similar — ignore
143
- }
144
- }
145
-
146
- // 4. Merge roles: deduplicate by roleType, attach claudeMd
147
- const roles = deduplicateRoles(sessionRoles, roleClaudes);
148
-
149
- // 5. Kanban
150
- const kanban = readFileOrNull(join(crewDir, 'context', 'kanban.md')) || '';
151
-
152
- // 6. Feature files from context/features/*.md
153
- const features = [];
154
- const featuresDir = join(crewDir, 'context', 'features');
155
- if (existsSync(featuresDir)) {
156
- try {
157
- const files = readdirSync(featuresDir)
158
- .filter(f => f.endsWith('.md') && f !== 'index.md')
159
- .sort();
160
- for (const f of files) {
161
- const content = readFileOrNull(join(featuresDir, f));
162
- if (content) {
163
- features.push({ name: f.replace('.md', ''), content });
164
- }
165
- }
166
- } catch {
167
- // ignore
168
- }
169
- }
170
-
171
- return { sharedClaudeMd, roles, kanban, features, teamType, language, sessionFeatures };
172
- }
173
-
174
- /**
175
- * Deduplicate Crew roles by roleType.
176
- * Crew may have dev-1, dev-2, dev-3 — collapse to a single "dev" for RolePlay.
177
- * Attaches per-role CLAUDE.md content.
178
- */
179
- function deduplicateRoles(sessionRoles, roleClaudes) {
180
- const byType = new Map(); // roleType -> first role seen
181
- const merged = [];
182
-
183
- for (const r of sessionRoles) {
184
- const type = r.roleType || r.name;
185
- const claudeMd = roleClaudes[r.name] || '';
186
-
187
- if (byType.has(type)) {
188
- // Already have this roleType — skip duplicate instance
189
- continue;
190
- }
191
-
192
- byType.set(type, true);
193
-
194
- // Use roleType as the RolePlay name (e.g. "developer" instead of "dev-1")
195
- // But keep "pm" and "designer" as-is since they're typically single-instance
196
- const name = type;
197
- // Strip instance suffix from displayName (e.g. "开发者-托瓦兹-1" → "开发者-托瓦兹")
198
- let displayName = r.displayName || name;
199
- displayName = displayName.replace(/-\d+$/, '');
200
-
201
- merged.push({
202
- name,
203
- displayName,
204
- icon: r.icon || '',
205
- description: r.description || '',
206
- claudeMd: claudeMd.substring(0, MAX_CLAUDE_MD_LEN),
207
- roleType: type,
208
- isDecisionMaker: !!r.isDecisionMaker,
209
- });
210
- }
211
-
212
- return merged;
213
- }
214
-
215
- // ---------------------------------------------------------------------------
216
- // Input validation
217
- // ---------------------------------------------------------------------------
218
-
219
- const ALLOWED_TEAM_TYPES = ['dev', 'writing', 'trading', 'video', 'custom'];
220
- const MAX_ROLE_NAME_LEN = 64;
221
- const MAX_DISPLAY_NAME_LEN = 128;
222
- const MAX_CLAUDE_MD_LEN = 8192;
223
- const MAX_ROLES = 10;
224
-
225
- /**
226
- * Validate and sanitize rolePlayConfig from the client.
227
- * Returns { valid: true, config: sanitizedConfig } or { valid: false, error: string }.
228
- *
229
- * @param {*} config - raw rolePlayConfig from client message
230
- * @returns {{ valid: boolean, config?: object, error?: string }}
231
- */
232
- export function validateRolePlayConfig(config) {
233
- if (!config || typeof config !== 'object') {
234
- return { valid: false, error: 'rolePlayConfig must be an object' };
235
- }
236
-
237
- // teamType
238
- const teamType = config.teamType;
239
- if (typeof teamType !== 'string' || !ALLOWED_TEAM_TYPES.includes(teamType)) {
240
- return { valid: false, error: `teamType must be one of: ${ALLOWED_TEAM_TYPES.join(', ')}` };
241
- }
242
-
243
- // language
244
- const language = config.language;
245
- if (language !== 'zh-CN' && language !== 'en') {
246
- return { valid: false, error: 'language must be "zh-CN" or "en"' };
247
- }
248
-
249
- // roles
250
- if (!Array.isArray(config.roles) || config.roles.length === 0) {
251
- return { valid: false, error: 'roles must be a non-empty array' };
252
- }
253
- if (config.roles.length > MAX_ROLES) {
254
- return { valid: false, error: `roles must have at most ${MAX_ROLES} entries` };
255
- }
256
-
257
- const sanitizedRoles = [];
258
- const seenNames = new Set();
259
-
260
- for (let i = 0; i < config.roles.length; i++) {
261
- const r = config.roles[i];
262
- if (!r || typeof r !== 'object') {
263
- return { valid: false, error: `roles[${i}] must be an object` };
264
- }
265
-
266
- // name: required string, alphanumeric + hyphens/underscores
267
- if (typeof r.name !== 'string' || r.name.length === 0 || r.name.length > MAX_ROLE_NAME_LEN) {
268
- return { valid: false, error: `roles[${i}].name must be a non-empty string (max ${MAX_ROLE_NAME_LEN} chars)` };
269
- }
270
- if (!/^[a-zA-Z0-9_-]+$/.test(r.name)) {
271
- return { valid: false, error: `roles[${i}].name must contain only alphanumeric, hyphens, or underscores` };
272
- }
273
- if (seenNames.has(r.name)) {
274
- return { valid: false, error: `roles[${i}].name "${r.name}" is duplicated` };
275
- }
276
- seenNames.add(r.name);
277
-
278
- // displayName: required string
279
- if (typeof r.displayName !== 'string' || r.displayName.length === 0 || r.displayName.length > MAX_DISPLAY_NAME_LEN) {
280
- return { valid: false, error: `roles[${i}].displayName must be a non-empty string (max ${MAX_DISPLAY_NAME_LEN} chars)` };
281
- }
282
-
283
- sanitizedRoles.push({
284
- name: r.name,
285
- displayName: r.displayName.substring(0, MAX_DISPLAY_NAME_LEN),
286
- icon: typeof r.icon === 'string' ? r.icon.substring(0, 8) : '',
287
- description: typeof r.description === 'string' ? r.description.substring(0, 500) : '',
288
- claudeMd: typeof r.claudeMd === 'string' ? r.claudeMd.substring(0, MAX_CLAUDE_MD_LEN) : '',
289
- });
290
- }
291
-
292
- return {
293
- valid: true,
294
- config: {
295
- roles: sanitizedRoles,
296
- teamType,
297
- language,
298
- }
299
- };
300
- }
301
-
302
- // ---------------------------------------------------------------------------
303
- // System prompt construction
304
- // ---------------------------------------------------------------------------
305
-
306
- /**
307
- * Build the appendSystemPrompt that tells Claude about the role play roles
308
- * and how to switch between them.
309
- *
310
- * @param {{ roles: Array, teamType: string, language: string, crewContext?: object }} config
311
- * @returns {string}
312
- */
313
- export function buildRolePlaySystemPrompt(config) {
314
- const { roles, teamType, language, crewContext } = config;
315
- const isZh = language === 'zh-CN';
316
-
317
- // Build role list
318
- const roleList = roles.map(r => {
319
- const desc = r.claudeMd || r.description || '';
320
- const icon = r.icon ? `${r.icon} ` : '';
321
- return `### ${icon}${r.displayName} (${r.name})\n${desc}`;
322
- }).join('\n\n');
323
-
324
- // Build workflow
325
- const workflow = getWorkflow(teamType, roles, isZh);
326
-
327
- const prompt = isZh
328
- ? buildZhPrompt(roleList, workflow, teamType)
329
- : buildEnPrompt(roleList, workflow, teamType);
330
-
331
- // Append .crew context if available
332
- if (crewContext) {
333
- const contextBlock = buildCrewContextBlock(crewContext, isZh);
334
- if (contextBlock) {
335
- return (prompt + '\n\n' + contextBlock).trim();
336
- }
337
- }
338
-
339
- return prompt.trim();
340
- }
341
-
342
- function buildZhPrompt(roleList, workflow, teamType) {
343
- const isDevTeam = teamType === 'dev' || teamType === 'custom';
344
-
345
- const outputRules = isDevTeam
346
- ? `- 不要在回复开头添加角色名称或"XX视角"等标题,对话界面已经显示了角色信息,直接以角色身份开始回复内容
347
- - 代码修改使用工具(Read, Edit, Write 等),不要在聊天中贴大段代码
348
- - 每个角色专注做自己的事,不要代替其他角色
349
- - Review 和 Test 角色如果发现问题,必须切回 Dev 修复后再继续`
350
- : `- 不要在回复开头添加角色名称或"XX视角"等标题,对话界面已经显示了角色信息,直接以角色身份开始回复内容
351
- - 每个角色专注做自己的事,不要代替其他角色
352
- - 角色之间可以自由讨论和质疑,鼓励迭代优化`;
353
-
354
- return `
355
- # 多角色协作模式
356
-
357
- 你正在以多角色协作方式完成任务。你将依次扮演不同角色,每个角色有独立的视角、人格和专业能力。
358
-
359
- ## 可用角色
360
-
361
- ${roleList}
362
-
363
- ## 角色切换规则
364
-
365
- ### 方式一:ROUTE 协议(推荐)
366
-
367
- 当一个角色完成工作需要交给另一个角色时,使用 ROUTE 块:
368
-
369
- \`\`\`
370
- ---ROUTE---
371
- to: {目标角色name}
372
- summary: {交接内容摘要}
373
- task: {任务ID,如 task-1}(可选)
374
- taskTitle: {任务标题}(可选)
375
- ---END_ROUTE---
376
- \`\`\`
377
-
378
- ROUTE 规则:
379
- - 一次可以输出多个 ROUTE 块(例如同时发给 reviewer 和 tester)
380
- - \`to\` 必须是有效的角色 name,或 \`human\` 表示需要用户输入
381
- - \`summary\` 是交给目标角色的具体任务和上下文
382
- - \`task\` / \`taskTitle\` 用于追踪 feature/任务(PM 分配任务时应填写)
383
- - ROUTE 块必须在角色输出的末尾
384
-
385
- ### 方式二:ROLE 信号(简单切换)
386
-
387
- ---ROLE: {角色name}---
388
-
389
- 直接切换到目标角色继续工作,适用于简单的角色轮转。
390
-
391
- ### 通用规则
392
-
393
- - 切换后,你必须完全以该角色的视角和人格思考、说话和行动
394
- - 第一条输出必须先切换到起始角色(通常是 PM)
395
- - 每次切换前留下交接信息(完成了什么、对下一角色的要求)
396
-
397
- ## 工作流程
398
-
399
- ${workflow}
400
-
401
- ## 交接规范
402
-
403
- 每次角色切换时,上一个角色必须留下清晰的交接信息:
404
- - 完成了什么
405
- - 未完成的部分(如有)
406
- - 对下一个角色的具体要求或注意事项
407
-
408
- ## 输出格式
409
-
410
- ${outputRules}
411
-
412
- ## 语言
413
-
414
- 使用中文交流。代码注释可以用英文。
415
- `;
416
- }
417
-
418
- function buildEnPrompt(roleList, workflow, teamType) {
419
- const isDevTeam = teamType === 'dev' || teamType === 'custom';
420
-
421
- const outputRules = isDevTeam
422
- ? `- Do not add role names or titles like "XX's perspective" at the beginning of responses; the chat UI already displays role information — start directly with the role's content
423
- - Use tools (Read, Edit, Write, etc.) for code changes, don't paste large code blocks in chat
424
- - Each role focuses on its own responsibility, don't do other roles' jobs
425
- - If Review or Test finds issues, must switch back to Dev to fix before continuing`
426
- : `- Do not add role names or titles like "XX's perspective" at the beginning of responses; the chat UI already displays role information — start directly with the role's content
427
- - Each role focuses on its own responsibility, don't do other roles' jobs
428
- - Roles can freely discuss and challenge each other, iterative optimization is encouraged`;
429
-
430
- return `
431
- # Multi-Role Collaboration Mode
432
-
433
- You are working in multi-role collaboration mode. You will take on different roles sequentially, each with its own perspective, personality, and expertise.
434
-
435
- ## Available Roles
436
-
437
- ${roleList}
438
-
439
- ## Role Switching Rules
440
-
441
- ### Method 1: ROUTE Protocol (Recommended)
442
-
443
- When a role finishes work and needs to hand off to another role, use a ROUTE block:
444
-
445
- \`\`\`
446
- ---ROUTE---
447
- to: {target_role_name}
448
- summary: {handoff content summary}
449
- task: {task ID, e.g. task-1} (optional)
450
- taskTitle: {task title} (optional)
451
- ---END_ROUTE---
452
- \`\`\`
453
-
454
- ROUTE rules:
455
- - You can output multiple ROUTE blocks at once (e.g., send to both reviewer and tester)
456
- - \`to\` must be a valid role name, or \`human\` to request user input
457
- - \`summary\` is the specific task and context for the target role
458
- - \`task\` / \`taskTitle\` are for tracking features/tasks (PM should fill these when assigning)
459
- - ROUTE blocks must be at the end of the role's output
460
-
461
- ### Method 2: ROLE Signal (Simple Switch)
462
-
463
- ---ROLE: {role_name}---
464
-
465
- Directly switch to the target role to continue working. Suitable for simple role rotation.
466
-
467
- ### General Rules
468
-
469
- - After switching, you must fully think, speak, and act from that role's perspective
470
- - Your first output must switch to the starting role (usually PM)
471
- - Before each switch, leave handoff information (what was done, requirements for next role)
472
-
473
- ## Workflow
474
-
475
- ${workflow}
476
-
477
- ## Handoff Convention
478
-
479
- Each role switch must include clear handoff information from the previous role:
480
- - What was completed
481
- - What remains (if any)
482
- - Specific requirements or notes for the next role
483
-
484
- ## Output Format
485
-
486
- ${outputRules}
487
-
488
- ## Language
489
-
490
- Communicate in English. Code comments in English.
491
- `;
492
- }
493
-
494
- // ---------------------------------------------------------------------------
495
- // Workflow generation per team type
496
- // ---------------------------------------------------------------------------
497
-
498
- function getWorkflow(teamType, roles, isZh) {
499
- const roleNames = roles.map(r => r.name);
500
-
501
- if (teamType === 'dev') {
502
- return buildDevWorkflow(roleNames, isZh);
503
- }
504
- if (teamType === 'writing') {
505
- return buildWritingWorkflow(roleNames, isZh);
506
- }
507
- if (teamType === 'trading') {
508
- return buildTradingWorkflow(roleNames, isZh);
509
- }
510
- if (teamType === 'video') {
511
- return buildVideoWorkflow(roleNames, isZh);
512
- }
513
-
514
- // Generic fallback for custom / unknown team types
515
- return isZh ? '按角色顺序依次完成任务。' : 'Complete tasks by following the role sequence.';
516
- }
517
-
518
- function buildDevWorkflow(roleNames, isZh) {
519
- const hasPm = roleNames.includes('pm');
520
- const hasDev = roleNames.includes('dev');
521
- const hasReviewer = roleNames.includes('reviewer');
522
- const hasTester = roleNames.includes('tester');
523
- const hasDesigner = roleNames.includes('designer');
524
-
525
- const steps = [];
526
-
527
- if (isZh) {
528
- if (hasPm) steps.push(`${steps.length + 1}. **PM** 分析需求,拆分任务,确定验收标准`);
529
- if (hasDesigner) steps.push(`${steps.length + 1}. **设计师** 确认交互方案(如涉及 UI)`);
530
- if (hasDev) steps.push(`${steps.length + 1}. **开发者** 实现代码(使用工具读写文件)`);
531
- if (hasReviewer) steps.push(`${steps.length + 1}. **审查者** Code Review(不通过 → 返回开发者修复)`);
532
- if (hasTester) steps.push(`${steps.length + 1}. **测试者** 运行测试 & 验证(有 bug → 返回开发者修复)`);
533
- if (hasPm) steps.push(`${steps.length + 1}. **PM** 验收总结`);
534
- } else {
535
- if (hasPm) steps.push(`${steps.length + 1}. **PM** analyzes requirements, breaks down tasks, defines acceptance criteria`);
536
- if (hasDesigner) steps.push(`${steps.length + 1}. **Designer** confirms interaction design (if UI involved)`);
537
- if (hasDev) steps.push(`${steps.length + 1}. **Dev** implements code (using tools to read/write files)`);
538
- if (hasReviewer) steps.push(`${steps.length + 1}. **Reviewer** code review (if fails → back to Dev)`);
539
- if (hasTester) steps.push(`${steps.length + 1}. **Tester** runs tests & verifies (if bugs → back to Dev)`);
540
- if (hasPm) steps.push(`${steps.length + 1}. **PM** acceptance & summary`);
541
- }
542
-
543
- return steps.join('\n');
544
- }
545
-
546
- function buildWritingWorkflow(roleNames, isZh) {
547
- const hasEditor = roleNames.includes('editor');
548
- const hasWriter = roleNames.includes('writer');
549
- const hasProofreader = roleNames.includes('proofreader');
550
-
551
- const steps = [];
552
-
553
- if (isZh) {
554
- if (hasEditor) steps.push(`${steps.length + 1}. **编辑** 分析需求,确定内容方向和框架`);
555
- if (hasWriter) steps.push(`${steps.length + 1}. **作者** 根据大纲撰写内容`);
556
- if (hasProofreader) steps.push(`${steps.length + 1}. **审校** 检查逻辑一致性、事实准确性和文字质量(不通过 → 返回作者修改)`);
557
- if (hasEditor) steps.push(`${steps.length + 1}. **编辑** 验收最终成果`);
558
- } else {
559
- if (hasEditor) steps.push(`${steps.length + 1}. **Editor** analyzes requirements, determines content direction and framework`);
560
- if (hasWriter) steps.push(`${steps.length + 1}. **Writer** writes content based on outline`);
561
- if (hasProofreader) steps.push(`${steps.length + 1}. **Proofreader** checks logical consistency, factual accuracy, and writing quality (if fails → back to Writer)`);
562
- if (hasEditor) steps.push(`${steps.length + 1}. **Editor** final acceptance of deliverables`);
563
- }
564
-
565
- return steps.join('\n');
566
- }
567
-
568
- function buildTradingWorkflow(roleNames, isZh) {
569
- const hasQuant = roleNames.includes('quant');
570
- const hasStrategist = roleNames.includes('strategist');
571
- const hasRisk = roleNames.includes('risk');
572
- const hasMacro = roleNames.includes('macro');
573
-
574
- const steps = [];
575
-
576
- if (isZh) {
577
- steps.push(`${steps.length + 1}. 用户提出分析需求`);
578
- if (hasQuant) steps.push(`${steps.length + 1}. **量化分析师** 执行脚本/获取数据,输出量化信号和分析结果`);
579
- if (hasStrategist && hasMacro) {
580
- steps.push(`${steps.length + 1}. **策略师** 和 **宏观研究员** 分析数据,各自给出观点`);
581
- } else if (hasStrategist) {
582
- steps.push(`${steps.length + 1}. **策略师** 分析数据,给出观点`);
583
- } else if (hasMacro) {
584
- steps.push(`${steps.length + 1}. **宏观研究员** 分析数据,给出观点`);
585
- }
586
- if (hasStrategist) steps.push(`${steps.length + 1}. **策略师** 综合各方分析,形成初步策略方案`);
587
- if (hasRisk) steps.push(`${steps.length + 1}. **风控官** 压力测试策略(不通过 → 返回策略师调整)`);
588
- steps.push(`${steps.length + 1}. 多角色可以相互讨论、质疑、补充(不必严格线性流转)`);
589
- if (hasStrategist) steps.push(`${steps.length + 1}. **策略师** 确认最终方案,输出结构化交易建议`);
590
- steps.push('');
591
- steps.push('关键原则:');
592
- if (hasQuant) steps.push('- 量化分析师可以随时被要求重新跑数据或换参数');
593
- steps.push('- 任何角色都可以 ROUTE 给任何角色提问/质疑');
594
- steps.push('- 工作流强调"基于数据的迭代优化"');
595
- } else {
596
- steps.push(`${steps.length + 1}. User submits analysis request`);
597
- if (hasQuant) steps.push(`${steps.length + 1}. **Quant** executes scripts/fetches data, outputs quantitative signals`);
598
- if (hasStrategist && hasMacro) {
599
- steps.push(`${steps.length + 1}. **Strategist** and **Macro Researcher** analyze data, each provides perspective`);
600
- } else if (hasStrategist) {
601
- steps.push(`${steps.length + 1}. **Strategist** analyzes data, provides perspective`);
602
- } else if (hasMacro) {
603
- steps.push(`${steps.length + 1}. **Macro Researcher** analyzes data, provides perspective`);
604
- }
605
- if (hasStrategist) steps.push(`${steps.length + 1}. **Strategist** synthesizes all analyses into preliminary strategy`);
606
- if (hasRisk) steps.push(`${steps.length + 1}. **Risk Officer** stress-tests strategy (if fails → back to Strategist)`);
607
- steps.push(`${steps.length + 1}. Roles can freely discuss, challenge, and supplement (not strictly linear)`);
608
- if (hasStrategist) steps.push(`${steps.length + 1}. **Strategist** confirms final plan, outputs structured recommendation`);
609
- steps.push('');
610
- steps.push('Key principles:');
611
- if (hasQuant) steps.push('- Quant can be asked to re-run data or change parameters at any time');
612
- steps.push('- Any role can ROUTE to any other role to ask questions or challenge');
613
- steps.push('- Workflow emphasizes "data-driven iterative optimization"');
614
- }
615
-
616
- return steps.join('\n');
617
- }
618
-
619
- function buildVideoWorkflow(roleNames, isZh) {
620
- const hasDirector = roleNames.includes('director');
621
- const hasWriter = roleNames.includes('writer');
622
- const hasProducer = roleNames.includes('producer');
623
-
624
- const steps = [];
625
-
626
- if (isZh) {
627
- if (hasDirector) steps.push(`${steps.length + 1}. **导演** 确定主题、情绪基调和视觉风格`);
628
- if (hasWriter) steps.push(`${steps.length + 1}. **编剧** 构思故事线,撰写分段脚本`);
629
- if (hasProducer) steps.push(`${steps.length + 1}. **制片** 审核可行性,生成最终 prompt 序列(不通过 → 返回编剧调整)`);
630
- if (hasDirector) steps.push(`${steps.length + 1}. **导演** 最终审核并验收`);
631
- } else {
632
- if (hasDirector) steps.push(`${steps.length + 1}. **Director** establishes theme, emotional tone, and visual style`);
633
- if (hasWriter) steps.push(`${steps.length + 1}. **Screenwriter** conceives storyline, writes segmented script`);
634
- if (hasProducer) steps.push(`${steps.length + 1}. **Producer** reviews feasibility, generates final prompt sequence (if fails → back to Screenwriter)`);
635
- if (hasDirector) steps.push(`${steps.length + 1}. **Director** final review and acceptance`);
636
- }
637
-
638
- return steps.join('\n');
639
- }
640
-
641
- // Re-export parseRoutes for use by claude.js and tests
642
- export { parseRoutes } from './crew/routing.js';
643
-
644
- /**
645
- * Initialize RolePlay route state on a session.
646
- * Called when a roleplay conversation is first created or resumed.
647
- *
648
- * @param {object} session - rolePlaySessions entry
649
- * @param {object} convState - ctx.conversations entry
650
- */
651
- export function initRolePlayRouteState(session, convState) {
652
- if (!session._routeInitialized) {
653
- session.currentRole = null;
654
- session.features = new Map();
655
- session.round = 0;
656
- session.roleStates = {};
657
- session.waitingHuman = false;
658
- session.waitingHumanContext = null;
659
-
660
- // Initialize per-role states
661
- for (const role of session.roles) {
662
- session.roleStates[role.name] = {
663
- currentTask: null,
664
- status: 'idle'
665
- };
666
- }
667
- session._routeInitialized = true;
668
- }
669
-
670
- // Also store accumulated text on convState for ROUTE detection during streaming
671
- if (!convState._roleplayAccumulated) {
672
- convState._roleplayAccumulated = '';
673
- }
674
- }
675
-
676
- /**
677
- * Detect a ROLE signal in text: ---ROLE: xxx---
678
- * Returns the role name if found at the end of accumulated text, null otherwise.
679
- */
680
- export function detectRoleSignal(text) {
681
- const match = text.match(/---ROLE:\s*([a-zA-Z0-9_-]+)\s*---/);
682
- return match ? match[1].toLowerCase() : null;
683
- }
684
-
685
- /**
686
- * Process ROUTE blocks detected in a completed turn's output.
687
- * Called from claude.js when a result message is received.
688
- *
689
- * Returns { routes, hasHumanRoute, continueRoles } for the caller to act on.
690
- *
691
- * @param {string} accumulatedText - full text output from the current turn
692
- * @param {object} session - rolePlaySessions entry
693
- * @returns {{ routes: Array, hasHumanRoute: boolean, continueRoles: Array<{to, prompt}> }}
694
- */
695
- export function processRolePlayRoutes(accumulatedText, session) {
696
- const routes = parseRoutes(accumulatedText);
697
- if (routes.length === 0) {
698
- return { routes: [], hasHumanRoute: false, continueRoles: [] };
699
- }
700
-
701
- const roleNames = new Set(session.roles.map(r => r.name));
702
- let hasHumanRoute = false;
703
- const continueRoles = [];
704
-
705
- for (const route of routes) {
706
- const { to, summary, taskId, taskTitle } = route;
707
-
708
- // Track features
709
- if (taskId && taskTitle && !session.features.has(taskId)) {
710
- session.features.set(taskId, { taskId, taskTitle, createdAt: Date.now() });
711
- }
712
-
713
- // Update source role state
714
- if (session.currentRole && session.roleStates[session.currentRole]) {
715
- session.roleStates[session.currentRole].status = 'idle';
716
- }
717
-
718
- if (to === 'human') {
719
- hasHumanRoute = true;
720
- session.waitingHuman = true;
721
- session.waitingHumanContext = {
722
- fromRole: session.currentRole,
723
- reason: 'requested',
724
- message: summary
725
- };
726
- } else if (roleNames.has(to)) {
727
- // Update target role state
728
- if (session.roleStates[to]) {
729
- session.roleStates[to].status = 'active';
730
- if (taskId) {
731
- session.roleStates[to].currentTask = { taskId, taskTitle };
732
- }
733
- }
734
-
735
- // Build prompt for the target role
736
- const fromRole = session.currentRole || 'unknown';
737
- const fromRoleConfig = session.roles.find(r => r.name === fromRole);
738
- const fromLabel = fromRoleConfig
739
- ? (fromRoleConfig.icon ? `${fromRoleConfig.icon} ${fromRoleConfig.displayName}` : fromRoleConfig.displayName)
740
- : fromRole;
741
-
742
- const targetRoleConfig = session.roles.find(r => r.name === to);
743
- const targetClaudeMd = targetRoleConfig?.claudeMd || '';
744
-
745
- let prompt = `来自 ${fromLabel} 的消息:\n${summary}\n\n`;
746
- if (targetClaudeMd) {
747
- prompt += `---\n<role-context>\n${targetClaudeMd}\n</role-context>\n\n`;
748
- }
749
- prompt += `你现在是 ${targetRoleConfig?.displayName || to}。请开始你的工作。完成后通过 ROUTE 块传递给下一个角色。`;
750
-
751
- continueRoles.push({ to, prompt, taskId, taskTitle });
752
- } else {
753
- console.warn(`[RolePlay] Unknown route target: ${to}`);
754
- }
755
- }
756
-
757
- // Increment round
758
- session.round++;
759
-
760
- return { routes, hasHumanRoute, continueRoles };
761
- }
762
-
763
- /**
764
- * Build the route event message to send to the frontend via WebSocket.
765
- *
766
- * @param {string} conversationId
767
- * @param {string} fromRole
768
- * @param {{ to, summary, taskId, taskTitle }} route
769
- * @returns {object} WebSocket message
770
- */
771
- export function buildRouteEventMessage(conversationId, fromRole, route) {
772
- return {
773
- type: 'roleplay_route',
774
- conversationId,
775
- from: fromRole,
776
- to: route.to,
777
- taskId: route.taskId || null,
778
- taskTitle: route.taskTitle || null,
779
- summary: route.summary || ''
780
- };
781
- }
782
-
783
- /**
784
- * Get the current RolePlay route state summary for frontend status updates.
785
- *
786
- * @param {string} conversationId
787
- * @returns {object|null} Route state summary or null if not a roleplay session
788
- */
789
- export function getRolePlayRouteState(conversationId) {
790
- const session = rolePlaySessions.get(conversationId);
791
- if (!session || !session._routeInitialized) return null;
792
-
793
- return {
794
- currentRole: session.currentRole,
795
- round: session.round,
796
- features: session.features ? Array.from(session.features.values()) : [],
797
- roleStates: session.roleStates || {},
798
- waitingHuman: session.waitingHuman || false,
799
- waitingHumanContext: session.waitingHumanContext || null
800
- };
801
- }
802
-
803
- // ---------------------------------------------------------------------------
804
- // .crew context block for system prompt
805
- // ---------------------------------------------------------------------------
806
-
807
- /**
808
- * Build the .crew context block to append to the system prompt.
809
- * Includes shared instructions, kanban, and feature history.
810
- */
811
- function buildCrewContextBlock(crewContext, isZh) {
812
- const sections = [];
813
-
814
- if (crewContext.sharedClaudeMd) {
815
- const header = isZh ? '## 项目共享指令(来自 .crew)' : '## Shared Project Instructions (from .crew)';
816
- sections.push(`${header}\n\n${crewContext.sharedClaudeMd}`);
817
- }
818
-
819
- if (crewContext.kanban) {
820
- const header = isZh ? '## 当前任务看板' : '## Current Task Board';
821
- sections.push(`${header}\n\n${crewContext.kanban}`);
822
- }
823
-
824
- if (crewContext.features && crewContext.features.length > 0) {
825
- const header = isZh ? '## 历史工作记录' : '## Work History';
826
- // Only include the last few features to avoid blowing up context
827
- const recentFeatures = crewContext.features.slice(-5);
828
- const featureTexts = recentFeatures.map(f => {
829
- // Truncate each feature to keep total size reasonable
830
- const content = f.content.length > 2000 ? f.content.substring(0, 2000) + '\n...(truncated)' : f.content;
831
- return `### ${f.name}\n${content}`;
832
- }).join('\n\n');
833
- sections.push(`${header}\n\n${featureTexts}`);
834
- }
835
-
836
- return sections.join('\n\n');
837
- }
838
-
839
- // ---------------------------------------------------------------------------
840
- // .crew context refresh (mtime-based change detection)
841
- // ---------------------------------------------------------------------------
842
-
843
- /**
844
- * Get mtime of a file, or 0 if it doesn't exist.
845
- * @param {string} filePath
846
- * @returns {number} mtime in ms
847
- */
848
- function getMtimeMs(filePath) {
849
- try {
850
- return statSync(filePath).mtimeMs;
851
- } catch {
852
- return 0;
853
- }
854
- }
855
-
856
- /**
857
- * Collect mtimes for all .crew context files that matter for RolePlay.
858
- * Returns a map of { relativePath → mtimeMs }.
859
- *
860
- * @param {string} projectDir
861
- * @returns {Map<string, number>}
862
- */
863
- function collectCrewContextMtimes(projectDir) {
864
- const crewDir = join(projectDir, '.crew');
865
- const mtimes = new Map();
866
-
867
- // Shared CLAUDE.md
868
- mtimes.set('CLAUDE.md', getMtimeMs(join(crewDir, 'CLAUDE.md')));
869
-
870
- // context/kanban.md
871
- mtimes.set('context/kanban.md', getMtimeMs(join(crewDir, 'context', 'kanban.md')));
872
-
873
- // context/features/*.md
874
- const featuresDir = join(crewDir, 'context', 'features');
875
- if (existsSync(featuresDir)) {
876
- try {
877
- const files = readdirSync(featuresDir).filter(f => f.endsWith('.md') && f !== 'index.md');
878
- for (const f of files) {
879
- mtimes.set(`context/features/${f}`, getMtimeMs(join(featuresDir, f)));
880
- }
881
- } catch { /* ignore */ }
882
- }
883
-
884
- // session.json (roles may change)
885
- mtimes.set('session.json', getMtimeMs(join(crewDir, 'session.json')));
886
-
887
- return mtimes;
888
- }
889
-
890
- /**
891
- * Check if .crew context has changed since last snapshot.
892
- *
893
- * @param {Map<string, number>} oldMtimes - previous mtime snapshot
894
- * @param {Map<string, number>} newMtimes - current mtime snapshot
895
- * @returns {boolean} true if any file has been added, removed, or modified
896
- */
897
- function hasCrewContextChanged(oldMtimes, newMtimes) {
898
- if (!oldMtimes) return true; // first check → always refresh
899
-
900
- // Defensive: if oldMtimes was deserialized from JSON (plain object, not Map),
901
- // treat as stale and force refresh
902
- if (!(oldMtimes instanceof Map)) return true;
903
-
904
- // Check for new or modified files
905
- for (const [path, mtime] of newMtimes) {
906
- if (!oldMtimes.has(path) || oldMtimes.get(path) !== mtime) return true;
907
- }
908
-
909
- // Check for deleted files
910
- for (const path of oldMtimes.keys()) {
911
- if (!newMtimes.has(path)) return true;
912
- }
913
-
914
- return false;
915
- }
916
-
917
- /**
918
- * Initialize the mtime snapshot for a RolePlay session without reloading context.
919
- * Use this when the caller has already loaded crewContext (e.g. resume path)
920
- * to avoid a redundant disk read.
921
- *
922
- * @param {string} projectDir - absolute path to project root
923
- * @param {object} rpSession - rolePlaySessions entry
924
- */
925
- export function initCrewContextMtimes(projectDir, rpSession) {
926
- if (!projectDir || !existsSync(join(projectDir, '.crew'))) return;
927
- rpSession._crewContextMtimes = collectCrewContextMtimes(projectDir);
928
- }
929
-
930
- /**
931
- * Refresh .crew context for a RolePlay session if files have changed.
932
- * Updates the session's crewContext and returns true if refreshed.
933
- *
934
- * Call this before building the system prompt (on resume, or before each turn).
935
- *
936
- * @param {string} projectDir - absolute path to project root
937
- * @param {object} rpSession - rolePlaySessions entry
938
- * @param {object} convState - ctx.conversations entry (has rolePlayConfig)
939
- * @returns {boolean} true if context was refreshed
940
- */
941
- export function refreshCrewContext(projectDir, rpSession, convState) {
942
- if (!projectDir || !existsSync(join(projectDir, '.crew'))) return false;
943
-
944
- const newMtimes = collectCrewContextMtimes(projectDir);
945
-
946
- // Compare with stored snapshot
947
- if (!hasCrewContextChanged(rpSession._crewContextMtimes, newMtimes)) {
948
- return false; // no change
949
- }
950
-
951
- // Reload
952
- const crewContext = loadCrewContext(projectDir);
953
- if (!crewContext) return false;
954
-
955
- // Update session and convState
956
- rpSession._crewContextMtimes = newMtimes;
957
-
958
- if (convState && convState.rolePlayConfig) {
959
- convState.rolePlayConfig.crewContext = crewContext;
960
- }
961
-
962
- console.log(`[RolePlay] Crew context refreshed from ${projectDir} (${crewContext.features.length} features, kanban: ${crewContext.kanban ? 'yes' : 'no'})`);
963
- return true;
964
- }
965
-
966
- // ---------------------------------------------------------------------------
967
- // .crew context write-back (RolePlay → .crew/context)
968
- // ---------------------------------------------------------------------------
969
-
970
- // Write lock for atomic write-back
971
- let _writeBackLock = Promise.resolve();
972
-
973
- /**
974
- * Atomic write: write to temp file then rename.
975
- * @param {string} filePath
976
- * @param {string} content
977
- */
978
- async function atomicWrite(filePath, content) {
979
- const tmpPath = filePath + '.tmp.' + Date.now();
980
- try {
981
- await fsp.writeFile(tmpPath, content);
982
- await fsp.rename(tmpPath, filePath);
983
- } catch (e) {
984
- // Clean up temp file on failure
985
- try { await fsp.unlink(tmpPath); } catch { /* ignore */ }
986
- throw e;
987
- }
988
- }
989
-
990
- /**
991
- * Write back RolePlay route output to .crew/context files.
992
- * Called after processRolePlayRoutes detects ROUTE blocks with task info.
993
- *
994
- * - Creates/updates context/features/{taskId}.md with route summary
995
- * - Serialized via write lock to prevent concurrent corruption
996
- *
997
- * NOTE: The atomic write (tmp→rename) prevents partial writes within this
998
- * process, and the serial lock prevents intra-process races. However, if a
999
- * Crew session in a separate process writes the same file concurrently,
1000
- * a TOCTOU race is possible (read-then-write is not locked across processes).
1001
- * In practice this is acceptable: Crew and RolePlay rarely write the same
1002
- * task file simultaneously, and the worst case is a lost append (not corruption).
1003
- *
1004
- * @param {string} projectDir - absolute path to project root
1005
- * @param {Array<{to: string, summary: string, taskId?: string, taskTitle?: string}>} routes
1006
- * @param {string} fromRole - name of the role that produced the output
1007
- * @param {object} rpSession - rolePlaySessions entry
1008
- */
1009
- export function writeBackRouteContext(projectDir, routes, fromRole, rpSession) {
1010
- if (!projectDir || !routes || routes.length === 0) return;
1011
-
1012
- const crewDir = join(projectDir, '.crew');
1013
- if (!existsSync(crewDir)) return;
1014
-
1015
- const doWriteBack = async () => {
1016
- const featuresDir = join(crewDir, 'context', 'features');
1017
-
1018
- for (const route of routes) {
1019
- const { taskId, taskTitle, summary, to } = route;
1020
- if (!taskId || !summary) continue;
1021
-
1022
- // ★ Sanitize taskId: only allow alphanumeric, hyphens, underscores
1023
- // Prevents path traversal (e.g. "../" in taskId from Claude output)
1024
- if (!/^[a-zA-Z0-9_-]+$/.test(taskId)) {
1025
- console.warn(`[RolePlay] Write-back rejected: invalid taskId "${taskId}"`);
1026
- continue;
1027
- }
1028
-
1029
- try {
1030
- await fsp.mkdir(featuresDir, { recursive: true });
1031
- const filePath = join(featuresDir, `${taskId}.md`);
1032
-
1033
- let content;
1034
- try {
1035
- content = await fsp.readFile(filePath, 'utf-8');
1036
- } catch {
1037
- // File doesn't exist — create it
1038
- const isZh = rpSession.language === 'zh-CN';
1039
- content = `# ${isZh ? 'Feature' : 'Feature'}: ${taskTitle || taskId}\n- task-id: ${taskId}\n\n## ${isZh ? '工作记录' : 'Work Record'}\n`;
1040
- }
1041
-
1042
- // Append the route record
1043
- const fromRoleConfig = rpSession.roles?.find(r => r.name === fromRole);
1044
- const fromLabel = fromRoleConfig
1045
- ? (fromRoleConfig.icon ? `${fromRoleConfig.icon} ${fromRoleConfig.displayName}` : fromRoleConfig.displayName)
1046
- : fromRole;
1047
- const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
1048
- const record = `\n### ${fromLabel} → ${to} - ${now}\n${summary}\n`;
1049
-
1050
- await atomicWrite(filePath, content + record);
1051
- console.log(`[RolePlay] Write-back: task ${taskId} updated (${fromRole} → ${to})`);
1052
- } catch (e) {
1053
- console.warn(`[RolePlay] Write-back failed for ${taskId}:`, e.message);
1054
- }
1055
- }
1056
- };
1057
-
1058
- // Serialize write-backs
1059
- _writeBackLock = _writeBackLock.then(doWriteBack, doWriteBack);
1060
- return _writeBackLock;
1061
- }