@yeaft/webchat-agent 0.1.78 → 0.1.80

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/conversation.js CHANGED
@@ -3,6 +3,8 @@ import { loadSessionHistory } from './history.js';
3
3
  import { startClaudeQuery } from './claude.js';
4
4
  import { crewSessions, loadCrewIndex } from './crew.js';
5
5
  import { rolePlaySessions, saveRolePlayIndex, removeRolePlaySession, loadRolePlayIndex, validateRolePlayConfig, initRolePlayRouteState, loadCrewContext, refreshCrewContext, initCrewContextMtimes } from './roleplay.js';
6
+ import { initRolePlayDir, writeSessionClaudeMd, generateSessionName, getDefaultRoles, getSessionDir } from './roleplay-dir.js';
7
+ import { addRolePlaySession, findRolePlaySessionByConversationId, setActiveRolePlaySession } from './roleplay-session.js';
6
8
 
7
9
  // Restore persisted roleplay sessions on module load (agent startup)
8
10
  loadRolePlayIndex();
@@ -149,16 +151,71 @@ export async function createConversation(msg) {
149
151
  }
150
152
  }
151
153
 
152
- console.log(`Creating conversation: ${conversationId} in ${effectiveWorkDir} (lazy start)`);
154
+ // ★ RolePlay: initialize .roleplay/ directory, generate session, set cwd
155
+ let rpSessionName = null;
156
+ let rpSessionWorkDir = effectiveWorkDir; // default: project root
157
+ if (rolePlayConfig) {
158
+ try {
159
+ const language = rolePlayConfig.language || 'zh-CN';
160
+
161
+ // 1. Ensure .roleplay/ directory structure exists
162
+ await initRolePlayDir(effectiveWorkDir, language);
163
+
164
+ // 2. Generate unique session name
165
+ rpSessionName = generateSessionName(
166
+ effectiveWorkDir,
167
+ rolePlayConfig.teamType,
168
+ msg.rolePlayConfig?.sessionName || null
169
+ );
170
+
171
+ // 3. Write session CLAUDE.md (role list, ROUTE protocol, workflow)
172
+ await writeSessionClaudeMd(effectiveWorkDir, rpSessionName, {
173
+ teamType: rolePlayConfig.teamType,
174
+ language,
175
+ roles: rolePlayConfig.roles,
176
+ });
177
+
178
+ // 4. Set cwd to session directory so Claude Code auto-reads its CLAUDE.md
179
+ rpSessionWorkDir = getSessionDir(effectiveWorkDir, rpSessionName);
180
+
181
+ // 5. Build roles snapshot for session.json
182
+ const rolesSnapshot = (rolePlayConfig.roles && rolePlayConfig.roles.length > 0)
183
+ ? rolePlayConfig.roles.map(r => ({
184
+ name: r.name,
185
+ displayName: r.displayName || r.name,
186
+ icon: r.icon || '',
187
+ }))
188
+ : getDefaultRoles(rolePlayConfig.teamType, language);
189
+
190
+ // 6. Persist to .roleplay/session.json
191
+ await addRolePlaySession(effectiveWorkDir, {
192
+ name: rpSessionName,
193
+ teamType: rolePlayConfig.teamType,
194
+ language,
195
+ projectDir: effectiveWorkDir,
196
+ conversationId,
197
+ roles: rolesSnapshot,
198
+ createdAt: Date.now(),
199
+ });
200
+
201
+ console.log(` RolePlay: initialized .roleplay/${rpSessionName}, cwd=${rpSessionWorkDir}`);
202
+ } catch (e) {
203
+ console.error('[createConversation] Failed to init .roleplay/ dir:', e);
204
+ // Non-fatal: fall back to project root as cwd
205
+ rpSessionWorkDir = effectiveWorkDir;
206
+ }
207
+ }
208
+
209
+ console.log(`Creating conversation: ${conversationId} in ${rpSessionWorkDir} (lazy start)`);
153
210
  if (username) console.log(` User: ${username} (${userId})`);
154
- if (rolePlayConfig) console.log(` RolePlay: teamType=${rolePlayConfig.teamType}, roles=${rolePlayConfig.roles?.length}`);
211
+ if (rolePlayConfig) console.log(` RolePlay: teamType=${rolePlayConfig.teamType}, roles=${rolePlayConfig.roles?.length}, session=${rpSessionName}`);
155
212
 
156
213
  // 只创建 conversation 状态,不启动 Claude 进程
157
214
  // Claude 进程会在用户发送第一条消息时启动 (见 handleUserInput)
158
215
  ctx.conversations.set(conversationId, {
159
216
  query: null,
160
217
  inputStream: null,
161
- workDir: effectiveWorkDir,
218
+ workDir: rpSessionWorkDir,
162
219
  claudeSessionId: null,
163
220
  createdAt: Date.now(),
164
221
  abortController: null,
@@ -169,6 +226,9 @@ export async function createConversation(msg) {
169
226
  username,
170
227
  disallowedTools: disallowedTools || null, // null = 使用全局默认
171
228
  rolePlayConfig: rolePlayConfig || null,
229
+ // Track the original project dir and session name for .roleplay/ operations
230
+ _rpProjectDir: rolePlayConfig ? effectiveWorkDir : null,
231
+ _rpSessionName: rpSessionName,
172
232
  usage: {
173
233
  inputTokens: 0,
174
234
  outputTokens: 0,
@@ -198,7 +258,7 @@ export async function createConversation(msg) {
198
258
  ctx.sendToServer({
199
259
  type: 'conversation_created',
200
260
  conversationId,
201
- workDir: effectiveWorkDir,
261
+ workDir: rpSessionWorkDir,
202
262
  userId,
203
263
  username,
204
264
  disallowedTools: disallowedTools || null,
@@ -260,6 +320,21 @@ export async function resumeConversation(msg) {
260
320
  ? { roles: rolePlayEntry.roles, teamType: rolePlayEntry.teamType, language: rolePlayEntry.language }
261
321
  : null;
262
322
 
323
+ // ★ RolePlay resume: look up session in .roleplay/session.json to restore cwd
324
+ let rpResumeWorkDir = effectiveWorkDir;
325
+ if (rolePlayConfig && rolePlayEntry) {
326
+ const rpProjectDir = rolePlayEntry.projectDir || effectiveWorkDir;
327
+ const rpDiskSession = findRolePlaySessionByConversationId(rpProjectDir, conversationId);
328
+ if (rpDiskSession && rpDiskSession.name) {
329
+ rpResumeWorkDir = getSessionDir(rpProjectDir, rpDiskSession.name);
330
+ // Re-activate the session
331
+ setActiveRolePlaySession(rpProjectDir, rpDiskSession.name).catch(e => {
332
+ console.warn('[Resume] Failed to update activeSession:', e.message);
333
+ });
334
+ console.log(`[Resume] RolePlay: restored session cwd=${rpResumeWorkDir}`);
335
+ }
336
+ }
337
+
263
338
  // ★ RolePlay resume: refresh .crew context to get latest kanban/features
264
339
  if (rolePlayConfig && rolePlayEntry) {
265
340
  const crewContext = loadCrewContext(effectiveWorkDir);
@@ -274,7 +349,7 @@ export async function resumeConversation(msg) {
274
349
  ctx.conversations.set(conversationId, {
275
350
  query: null,
276
351
  inputStream: null,
277
- workDir: effectiveWorkDir,
352
+ workDir: rpResumeWorkDir,
278
353
  claudeSessionId: claudeSessionId, // 保存要恢复的 session ID
279
354
  createdAt: Date.now(),
280
355
  abortController: null,
package/crew/session.js CHANGED
@@ -6,7 +6,7 @@ import { join, isAbsolute } from 'path';
6
6
  import ctx from '../context.js';
7
7
  import { getMessages } from '../crew-i18n.js';
8
8
  import { initWorktrees } from './worktree.js';
9
- import { initSharedDir, writeRoleClaudeMd, updateSharedClaudeMd } from './shared-dir.js';
9
+ import { initSharedDir, writeRoleClaudeMd, updateSharedClaudeMd, backupMemoryContent } from './shared-dir.js';
10
10
  import {
11
11
  loadCrewIndex, upsertCrewIndex, removeFromCrewIndex,
12
12
  loadSessionMeta, saveSessionMeta, loadSessionMessages, getMaxShardIndex
@@ -366,6 +366,9 @@ export async function handleDeleteCrewDir(msg) {
366
366
  if (!isValidProjectDir(projectDir)) return;
367
367
  const crewDir = join(projectDir, '.crew');
368
368
  try {
369
+ // 提取并备份记忆内容(删除前)
370
+ await backupMemoryContent(crewDir);
371
+
369
372
  // 删除 Crew 模板定义
370
373
  await fs.rm(join(crewDir, 'CLAUDE.md'), { force: true }).catch(() => {});
371
374
  await fs.rm(join(crewDir, 'roles'), { recursive: true, force: true }).catch(() => {});
@@ -4,13 +4,98 @@
4
4
  */
5
5
  import { promises as fs } from 'fs';
6
6
  import { join } from 'path';
7
- import { getMessages } from '../crew-i18n.js';
7
+ import { getMessages, getAllMemoryTitles } from '../crew-i18n.js';
8
8
 
9
9
  /** Format role label: "icon displayName" or just "displayName" if no icon */
10
10
  function roleLabel(r) {
11
11
  return r.icon ? `${r.icon} ${r.displayName}` : r.displayName;
12
12
  }
13
13
 
14
+ const MEMORY_BACKUP_FILE = '.memory-backup.json';
15
+
16
+ /**
17
+ * Extract user-written content after a memory section title.
18
+ * Searches for any known locale's title (e.g. "# 共享记忆" or "# Shared Memory"),
19
+ * returns the trimmed content after the title line until EOF or next top-level heading.
20
+ * Returns null if section not found or content is only the default placeholder.
21
+ */
22
+ function extractMemorySection(fileContent, titles, defaults) {
23
+ for (const title of titles) {
24
+ const idx = fileContent.indexOf(title);
25
+ if (idx === -1) continue;
26
+ // Content starts after the title line
27
+ const afterTitle = fileContent.slice(idx + title.length);
28
+ // Find next top-level heading (# at start of line) — that's where memory ends
29
+ const nextHeading = afterTitle.search(/\n#\s/);
30
+ const raw = nextHeading === -1 ? afterTitle : afterTitle.slice(0, nextHeading);
31
+ const trimmed = raw.trim();
32
+ // Skip if empty or is just the default placeholder
33
+ if (!trimmed) return null;
34
+ for (const d of defaults) {
35
+ if (trimmed === d.trim()) return null;
36
+ }
37
+ return trimmed;
38
+ }
39
+ return null;
40
+ }
41
+
42
+ /**
43
+ * Backup memory content from .crew/CLAUDE.md and .crew/roles/*/CLAUDE.md
44
+ * before deletion. Writes .crew/.memory-backup.json.
45
+ */
46
+ export async function backupMemoryContent(crewDir) {
47
+ const { sharedTitles, sharedDefaults, personalTitles, personalDefaults } = getAllMemoryTitles();
48
+ const backup = { shared: null, roles: {} };
49
+
50
+ // Extract shared memory from .crew/CLAUDE.md
51
+ try {
52
+ const sharedContent = await fs.readFile(join(crewDir, 'CLAUDE.md'), 'utf-8');
53
+ backup.shared = extractMemorySection(sharedContent, sharedTitles, sharedDefaults);
54
+ } catch { /* CLAUDE.md doesn't exist — skip */ }
55
+
56
+ // Extract personal memory from each role's CLAUDE.md
57
+ try {
58
+ const rolesDir = join(crewDir, 'roles');
59
+ const roleDirs = await fs.readdir(rolesDir);
60
+ for (const roleName of roleDirs) {
61
+ try {
62
+ const roleClaudeMd = await fs.readFile(join(rolesDir, roleName, 'CLAUDE.md'), 'utf-8');
63
+ const memory = extractMemorySection(roleClaudeMd, personalTitles, personalDefaults);
64
+ if (memory) {
65
+ backup.roles[roleName] = memory;
66
+ }
67
+ } catch { /* Role dir or file missing — skip */ }
68
+ }
69
+ } catch { /* roles/ doesn't exist — skip */ }
70
+
71
+ // Only write backup if there's something to preserve
72
+ if (backup.shared || Object.keys(backup.roles).length > 0) {
73
+ await fs.writeFile(join(crewDir, MEMORY_BACKUP_FILE), JSON.stringify(backup, null, 2));
74
+ console.log(`[Crew] Memory backup saved: shared=${!!backup.shared}, roles=${Object.keys(backup.roles).join(',') || 'none'}`);
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Load memory backup from .crew/.memory-backup.json, returns null if not found.
80
+ */
81
+ async function loadMemoryBackup(sharedDir) {
82
+ try {
83
+ const data = await fs.readFile(join(sharedDir, MEMORY_BACKUP_FILE), 'utf-8');
84
+ return JSON.parse(data);
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Delete memory backup file after successful restore.
92
+ */
93
+ async function cleanupMemoryBackup(sharedDir) {
94
+ try {
95
+ await fs.rm(join(sharedDir, MEMORY_BACKUP_FILE), { force: true });
96
+ } catch { /* ignore */ }
97
+ }
98
+
14
99
  /**
15
100
  * 初始化共享目录
16
101
  */
@@ -27,6 +112,9 @@ export async function initSharedDir(sharedDir, roles, projectDir, language = 'zh
27
112
 
28
113
  // 生成 .crew/CLAUDE.md(共享级)
29
114
  await writeSharedClaudeMd(sharedDir, roles, projectDir, language);
115
+
116
+ // 清理记忆备份文件(已在 write 阶段恢复)
117
+ await cleanupMemoryBackup(sharedDir);
30
118
  }
31
119
 
32
120
  /**
@@ -52,6 +140,10 @@ export async function initRoleDir(sharedDir, role, language = 'zh-CN', allRoles
52
140
  export async function writeSharedClaudeMd(sharedDir, roles, projectDir, language = 'zh-CN') {
53
141
  const m = getMessages(language);
54
142
 
143
+ // Check for memory backup to restore
144
+ const backup = await loadMemoryBackup(sharedDir);
145
+ const sharedMemoryContent = (backup && backup.shared) ? backup.shared : m.sharedMemoryDefault;
146
+
55
147
  const claudeMd = `${m.projectGoal}
56
148
 
57
149
  ${m.projectCodePath}
@@ -73,7 +165,7 @@ ${m.worktreeRulesContent}
73
165
  ${m.featureRecordShared}
74
166
 
75
167
  ${m.sharedMemoryTitle}
76
- ${m.sharedMemoryDefault}
168
+ ${sharedMemoryContent}
77
169
  `;
78
170
 
79
171
  await fs.writeFile(join(sharedDir, 'CLAUDE.md'), claudeMd);
@@ -129,6 +221,12 @@ export async function writeRoleClaudeMd(sharedDir, role, language = 'zh-CN', all
129
221
  const roleDir = join(sharedDir, 'roles', role.name);
130
222
  const m = getMessages(language);
131
223
 
224
+ // Check for memory backup to restore
225
+ const backup = await loadMemoryBackup(sharedDir);
226
+ const personalMemoryContent = (backup && backup.roles && backup.roles[role.name])
227
+ ? backup.roles[role.name]
228
+ : m.personalMemoryDefault;
229
+
132
230
  // Resolve generic ROUTE targets to actual instance names
133
231
  const resolvedClaudeMd = resolveRouteTargets(role.claudeMd || role.description, role, allRoles);
134
232
 
@@ -147,7 +245,7 @@ ${m.codeWorkDirNote}
147
245
 
148
246
  claudeMd += `
149
247
  ${m.personalMemory}
150
- ${m.personalMemoryDefault}
248
+ ${personalMemoryContent}
151
249
  `;
152
250
 
153
251
  await fs.writeFile(join(roleDir, 'CLAUDE.md'), claudeMd);
package/crew-i18n.js CHANGED
@@ -498,3 +498,23 @@ Roles don't need to manually create or update these files.`,
498
498
  export function getMessages(language) {
499
499
  return messages[language] || messages['zh-CN'];
500
500
  }
501
+
502
+ /**
503
+ * Get all memory-related titles and defaults across all locales.
504
+ * Used to detect and extract user-written memory content regardless of language.
505
+ * @returns {{ sharedTitles: string[], sharedDefaults: string[], personalTitles: string[], personalDefaults: string[] }}
506
+ */
507
+ export function getAllMemoryTitles() {
508
+ const sharedTitles = [];
509
+ const sharedDefaults = [];
510
+ const personalTitles = [];
511
+ const personalDefaults = [];
512
+ for (const lang of Object.keys(messages)) {
513
+ const m = messages[lang];
514
+ if (m.sharedMemoryTitle) sharedTitles.push(m.sharedMemoryTitle);
515
+ if (m.sharedMemoryDefault) sharedDefaults.push(m.sharedMemoryDefault);
516
+ if (m.personalMemory) personalTitles.push(m.personalMemory);
517
+ if (m.personalMemoryDefault) personalDefaults.push(m.personalMemoryDefault);
518
+ }
519
+ return { sharedTitles, sharedDefaults, personalTitles, personalDefaults };
520
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.78",
3
+ "version": "0.1.80",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,301 @@
1
+ /**
2
+ * RolePlay — .roleplay/ directory and CLAUDE.md management.
3
+ *
4
+ * Analogous to agent/crew/shared-dir.js but for the single-process
5
+ * RolePlay collaboration mode.
6
+ *
7
+ * Directory structure:
8
+ * .roleplay/
9
+ * ├── CLAUDE.md (shared instructions, inherited by all sessions)
10
+ * ├── session.json (session index)
11
+ * ├── roles/ (one subdirectory per session)
12
+ * │ └── {session-name}/
13
+ * │ └── CLAUDE.md (Claude Code cwd points here)
14
+ * └── context/
15
+ * ├── kanban.md
16
+ * └── features/
17
+ */
18
+ import { promises as fs } from 'fs';
19
+ import { join } from 'path';
20
+ import { existsSync, readdirSync } from 'fs';
21
+ import { getRolePlayMessages } from './roleplay-i18n.js';
22
+
23
+ // ─── Team type → default role set mapping ───────────────────────────
24
+ // Each entry is a list of role names (keys into roleTemplates in i18n).
25
+ const TEAM_ROLES = {
26
+ dev: ['pm', 'dev', 'reviewer', 'tester'],
27
+ writing: ['planner', 'writer', 'editor'],
28
+ trading: ['strategist', 'analyst', 'macro', 'risk', 'trader'],
29
+ video: ['director', 'scriptwriter', 'storyboard'],
30
+ custom: ['pm', 'dev', 'reviewer', 'tester'], // default same as dev
31
+ };
32
+
33
+ // ─── Session name constraints ───────────────────────────────────────
34
+ const SESSION_NAME_RE = /^[a-z0-9-]+$/;
35
+ const MAX_SESSION_NAME_LEN = 64;
36
+
37
+ /**
38
+ * Generate a session directory name.
39
+ *
40
+ * Format: `{teamType}-{customName|"team"}-{YYYYMMDD}[-{seq}]`
41
+ *
42
+ * If a session with the same name already exists under .roleplay/roles/,
43
+ * a sequence number is appended (e.g. `-2`, `-3`).
44
+ *
45
+ * @param {string} projectDir - absolute path to project root
46
+ * @param {string} teamType - dev | writing | trading | video | custom
47
+ * @param {string} [customName] - user-provided name (optional)
48
+ * @returns {string} unique session name
49
+ */
50
+ export function generateSessionName(projectDir, teamType, customName) {
51
+ const now = new Date();
52
+ const datePart = [
53
+ now.getFullYear(),
54
+ String(now.getMonth() + 1).padStart(2, '0'),
55
+ String(now.getDate()).padStart(2, '0'),
56
+ ].join('');
57
+
58
+ const namePart = sanitizeNamePart(customName) || 'team';
59
+ const base = `${teamType}-${namePart}-${datePart}`;
60
+
61
+ // Check for duplicates under .roleplay/roles/
62
+ const rolesDir = join(projectDir, '.roleplay', 'roles');
63
+ const existing = new Set();
64
+ if (existsSync(rolesDir)) {
65
+ try {
66
+ for (const d of readdirSync(rolesDir, { withFileTypes: true })) {
67
+ if (d.isDirectory()) existing.add(d.name);
68
+ }
69
+ } catch {
70
+ // permission error — treat as empty
71
+ }
72
+ }
73
+
74
+ if (!existing.has(base)) return base;
75
+
76
+ // Append sequence number
77
+ for (let seq = 2; seq <= 999; seq++) {
78
+ const candidate = `${base}-${seq}`;
79
+ if (!existing.has(candidate)) return candidate;
80
+ }
81
+
82
+ // Extremely unlikely: fall back to timestamp suffix
83
+ return `${base}-${Date.now()}`;
84
+ }
85
+
86
+ /**
87
+ * Sanitize user-provided name part:
88
+ * - lowercase
89
+ * - replace non-alphanumeric with hyphens
90
+ * - collapse consecutive hyphens
91
+ * - trim leading/trailing hyphens
92
+ * - enforce max length
93
+ *
94
+ * Returns empty string if input is empty/invalid.
95
+ */
96
+ function sanitizeNamePart(raw) {
97
+ if (!raw || typeof raw !== 'string') return '';
98
+ let s = raw.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
99
+ if (s.length > 30) s = s.substring(0, 30).replace(/-$/, '');
100
+ return SESSION_NAME_RE.test(s) ? s : '';
101
+ }
102
+
103
+ // ─── Directory initialization ───────────────────────────────────────
104
+
105
+ /**
106
+ * Ensure .roleplay/ directory structure exists.
107
+ * Creates the top-level dirs if absent, writes shared CLAUDE.md
108
+ * only on first creation (preserves user edits after that).
109
+ *
110
+ * @param {string} projectDir - absolute path to project root
111
+ * @param {string} [language='zh-CN']
112
+ */
113
+ export async function initRolePlayDir(projectDir, language = 'zh-CN') {
114
+ const rpDir = join(projectDir, '.roleplay');
115
+
116
+ await fs.mkdir(rpDir, { recursive: true });
117
+ await fs.mkdir(join(rpDir, 'roles'), { recursive: true });
118
+ await fs.mkdir(join(rpDir, 'context'), { recursive: true });
119
+ await fs.mkdir(join(rpDir, 'context', 'features'), { recursive: true });
120
+
121
+ // Write shared CLAUDE.md only if it doesn't exist
122
+ const sharedMdPath = join(rpDir, 'CLAUDE.md');
123
+ try {
124
+ await fs.access(sharedMdPath);
125
+ // Already exists — don't overwrite (user may have edited it)
126
+ } catch {
127
+ await writeRolePlaySharedClaudeMd(projectDir, language);
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Write (or overwrite) .roleplay/CLAUDE.md — the shared-level instructions
133
+ * inherited by all sessions via Claude Code's CLAUDE.md lookup chain.
134
+ *
135
+ * @param {string} projectDir
136
+ * @param {string} [language='zh-CN']
137
+ */
138
+ export async function writeRolePlaySharedClaudeMd(projectDir, language = 'zh-CN') {
139
+ const m = getRolePlayMessages(language);
140
+ const rpDir = join(projectDir, '.roleplay');
141
+
142
+ const content = `${m.sharedTitle}
143
+
144
+ ${m.projectPath}
145
+ ${projectDir}
146
+ ${m.useAbsolutePath}
147
+
148
+ ${m.workMode}
149
+ ${m.workModeContent}
150
+
151
+ ${m.workConventions}
152
+ ${m.workConventionsContent}
153
+
154
+ ${m.crewRelation}
155
+ ${m.crewRelationContent}
156
+
157
+ ${m.sharedMemory}
158
+ ${m.sharedMemoryDefault}
159
+ `;
160
+
161
+ await fs.writeFile(join(rpDir, 'CLAUDE.md'), content);
162
+ }
163
+
164
+ // ─── Session CLAUDE.md generation ───────────────────────────────────
165
+
166
+ /**
167
+ * Write .roleplay/roles/{sessionName}/CLAUDE.md — session-level config.
168
+ *
169
+ * This file is the core configuration that Claude Code reads automatically
170
+ * (cwd is set to this directory). It contains:
171
+ * - Session metadata (name, teamType, language)
172
+ * - Full role list with descriptions (generated from teamType or custom roles)
173
+ * - ROUTE protocol reference
174
+ * - Workflow for this team type
175
+ * - Project path reference
176
+ * - Session memory section
177
+ *
178
+ * @param {string} projectDir - project root
179
+ * @param {string} sessionName - session directory name
180
+ * @param {object} config - { teamType, language, roles?, projectDir? }
181
+ * - config.roles: optional custom role array from RolePlay config.
182
+ * If provided and non-empty, these are used instead of default team roles.
183
+ * Each role: { name, displayName, icon?, description?, claudeMd? }
184
+ */
185
+ export async function writeSessionClaudeMd(projectDir, sessionName, config) {
186
+ const { teamType = 'dev', language = 'zh-CN', roles: customRoles } = config;
187
+ const m = getRolePlayMessages(language);
188
+
189
+ const sessionDir = join(projectDir, '.roleplay', 'roles', sessionName);
190
+ await fs.mkdir(sessionDir, { recursive: true });
191
+
192
+ // Build role list section
193
+ const roleSection = buildRoleSection(teamType, language, customRoles);
194
+
195
+ // Build workflow section
196
+ const workflow = (teamType === 'dev')
197
+ ? m.devWorkflow
198
+ : m.genericWorkflow;
199
+
200
+ const content = `${m.sessionTitle(sessionName)}
201
+
202
+ ${m.teamTypeLabel}
203
+ ${teamType}
204
+
205
+ ${m.languageLabel}
206
+ ${language}
207
+
208
+ ${m.roleListTitle}
209
+
210
+ ${roleSection}
211
+
212
+ ${m.routeProtocol}
213
+
214
+ ${m.workflowTitle}
215
+
216
+ ${workflow}
217
+
218
+ ${m.projectPathTitle}
219
+ ${projectDir}
220
+ ${m.useAbsolutePath}
221
+
222
+ ${m.sessionMemory}
223
+ ${m.sessionMemoryDefault}
224
+ `;
225
+
226
+ await fs.writeFile(join(sessionDir, 'CLAUDE.md'), content);
227
+ }
228
+
229
+ /**
230
+ * Build the role list section for a session CLAUDE.md.
231
+ *
232
+ * Strategy:
233
+ * 1. If custom roles are provided (from RolePlay config), use them directly.
234
+ * For each custom role, look up a matching roleTemplate for extra detail,
235
+ * but prefer the custom role's claudeMd/description if provided.
236
+ * 2. Otherwise, use the default role set for the teamType from TEAM_ROLES.
237
+ */
238
+ function buildRoleSection(teamType, language, customRoles) {
239
+ const m = getRolePlayMessages(language);
240
+ const templates = m.roleTemplates;
241
+
242
+ if (customRoles && customRoles.length > 0) {
243
+ return customRoles.map(r => {
244
+ const tmpl = templates[r.name];
245
+ // Prefer custom claudeMd, then custom description, then template
246
+ const body = r.claudeMd || r.description || (tmpl ? tmpl.content : '');
247
+ const heading = tmpl
248
+ ? tmpl.heading
249
+ : `## ${r.icon || ''} ${r.displayName || r.name} (${r.name})`.replace(/\s{2,}/g, ' ');
250
+ return `${heading}\n${body}`;
251
+ }).join('\n\n');
252
+ }
253
+
254
+ // Default: use TEAM_ROLES mapping
255
+ const roleNames = TEAM_ROLES[teamType] || TEAM_ROLES.dev;
256
+ return roleNames.map(name => {
257
+ const tmpl = templates[name];
258
+ if (!tmpl) return `## ${name}\n(No template available)`;
259
+ return `${tmpl.heading}\n${tmpl.content}`;
260
+ }).join('\n\n');
261
+ }
262
+
263
+ /**
264
+ * Get the default role list for a team type.
265
+ * Used by session creation to populate session.json roles snapshot.
266
+ *
267
+ * @param {string} teamType
268
+ * @param {string} language
269
+ * @returns {Array<{name: string, displayName: string, icon: string}>}
270
+ */
271
+ export function getDefaultRoles(teamType, language = 'zh-CN') {
272
+ const m = getRolePlayMessages(language);
273
+ const templates = m.roleTemplates;
274
+ const roleNames = TEAM_ROLES[teamType] || TEAM_ROLES.dev;
275
+
276
+ return roleNames.map(name => {
277
+ const tmpl = templates[name];
278
+ if (!tmpl) return { name, displayName: name, icon: '' };
279
+
280
+ // Extract icon and displayName from heading: "## 📋 PM-乔布斯 (pm)"
281
+ const headingMatch = tmpl.heading.match(/^##\s*(\S+)\s+(.+?)\s*\((\w+)\)\s*$/);
282
+ if (headingMatch) {
283
+ return {
284
+ name: headingMatch[3],
285
+ displayName: headingMatch[2],
286
+ icon: headingMatch[1],
287
+ };
288
+ }
289
+ return { name, displayName: name, icon: '' };
290
+ });
291
+ }
292
+
293
+ /**
294
+ * Get the session directory path.
295
+ * @param {string} projectDir
296
+ * @param {string} sessionName
297
+ * @returns {string} absolute path to .roleplay/roles/{sessionName}/
298
+ */
299
+ export function getSessionDir(projectDir, sessionName) {
300
+ return join(projectDir, '.roleplay', 'roles', sessionName);
301
+ }
@@ -0,0 +1,445 @@
1
+ /**
2
+ * RolePlay i18n — localized template strings for .roleplay/ directory files.
3
+ *
4
+ * Separated from crew-i18n.js because RolePlay CLAUDE.md templates have
5
+ * different structure (single-process, no worktree rules, session-scoped).
6
+ *
7
+ * Supported languages: 'zh-CN' (default), 'en'
8
+ */
9
+
10
+ const messages = {
11
+ 'zh-CN': {
12
+ // ── .roleplay/CLAUDE.md (shared level) ──
13
+ sharedTitle: '# RolePlay 共享指令',
14
+ projectPath: '# 项目路径',
15
+ useAbsolutePath: '所有代码操作请使用此绝对路径。',
16
+ workMode: '# 工作模式',
17
+ workModeContent: `RolePlay 是单进程多角色协作模式。一个 Claude 实例依次扮演不同角色,通过 ROUTE 协议切换。
18
+ 与 Crew(多进程、每角色独立 Claude 实例)的区别:
19
+ - 所有角色共享同一个上下文窗口
20
+ - 角色切换是即时的(无需跨进程通信)
21
+ - 适合轻量级协作和快速迭代`,
22
+ workConventions: '# 工作约定',
23
+ workConventionsContent: `- 文档产出写入 .roleplay/context/ 目录
24
+ - 代码修改使用项目路径的绝对路径
25
+ - 每个角色专注自己的职责,不越界`,
26
+ crewRelation: '# 与 .crew 的关系',
27
+ crewRelationContent: `- .roleplay/context/ 可以读取 .crew/context/ 的内容
28
+ - .crew 的共享记忆和看板对 RolePlay 可见
29
+ - RolePlay 不修改 .crew/ 的任何内容(只读)`,
30
+ sharedMemory: '# 共享记忆',
31
+ sharedMemoryDefault: '_所有 session 共同维护,记录项目级的共识和决策。_',
32
+
33
+ // ── .roleplay/roles/{session}/CLAUDE.md (session level) ──
34
+ sessionTitle: (name) => `# RolePlay Session: ${name}`,
35
+ teamTypeLabel: '# 团队类型',
36
+ languageLabel: '# 语言',
37
+ roleListTitle: '# 角色列表',
38
+
39
+ // Role templates per teamType
40
+ roleTemplates: {
41
+ pm: {
42
+ heading: '## 📋 PM-乔布斯 (pm)',
43
+ content: `你是 PM-乔布斯。你的职责:
44
+ - 分析用户需求,理解意图
45
+ - 将需求拆分为可执行的开发任务
46
+ - 定义验收标准
47
+ - 最终验收开发成果
48
+
49
+ 风格:简洁、注重用户价值、善于抓住本质。`,
50
+ },
51
+ dev: {
52
+ heading: '## 💻 开发者-托瓦兹 (dev)',
53
+ content: `你是开发者-托瓦兹。你的职责:
54
+ - 设计技术方案和架构
55
+ - 使用工具(Read, Edit, Write, Bash)实现代码
56
+ - 确保代码质量和可维护性
57
+ - 修复 reviewer 和 tester 提出的问题
58
+
59
+ 风格:追求代码简洁优雅,重视性能和可维护性。不写废话,直接动手。`,
60
+ },
61
+ reviewer: {
62
+ heading: '## 🔍 审查者-马丁 (reviewer)',
63
+ content: `你是审查者-马丁。你的职责:
64
+ - 仔细审查开发者的代码变更
65
+ - 检查:代码风格、命名规范、架构合理性、边界情况、安全漏洞
66
+ - 如果有问题,明确指出并说明修改建议
67
+ - 确认通过后明确说"LGTM"
68
+
69
+ 风格:严格但友善,注重最佳实践,善于发现潜在问题。`,
70
+ },
71
+ tester: {
72
+ heading: '## 🧪 测试者-贝克 (tester)',
73
+ content: `你是测试者-贝克。你的职责:
74
+ - 使用 Bash 工具运行测试
75
+ - 验证功能是否按预期工作
76
+ - 检查边界情况和异常处理
77
+ - 如果有 bug,明确描述复现步骤
78
+
79
+ 风格:测试驱动思维,善于发现边界情况,追求可靠性。`,
80
+ },
81
+ designer: {
82
+ heading: '## 🎨 设计师-拉姆斯 (designer)',
83
+ content: `你是设计师-拉姆斯。你的职责:
84
+ - 设计用户交互流程和页面布局
85
+ - 确保视觉一致性和用户体验
86
+ - 输出设计方案给开发者实现
87
+
88
+ 风格:Less but better,追求极致简洁。`,
89
+ },
90
+ // Writing team
91
+ planner: {
92
+ heading: '## 📋 策划-曹雪芹 (planner)',
93
+ content: `你是策划-曹雪芹。你的职责:
94
+ - 制定整体创作方向和大纲
95
+ - 审核内容质量和一致性
96
+ - 做出关键创作决策
97
+
98
+ 风格:注重故事内核,善于把控整体节奏。`,
99
+ },
100
+ writer: {
101
+ heading: '## ✍️ 执笔师-鲁迅 (writer)',
102
+ content: `你是执笔师-鲁迅。你的职责:
103
+ - 根据大纲撰写具体内容
104
+ - 把控文字质量和风格一致性
105
+ - 按照设计师的节奏方案写作
106
+
107
+ 风格:文字犀利,善于用细节打动人。`,
108
+ },
109
+ editor: {
110
+ heading: '## 📝 审稿师 (editor)',
111
+ content: `你是审稿师。你的职责:
112
+ - 审核文字质量、逻辑一致性
113
+ - 检查错别字、语法、标点
114
+ - 确认内容符合整体方向
115
+
116
+ 风格:严谨细致,注重可读性。`,
117
+ },
118
+ // Trading team
119
+ strategist: {
120
+ heading: '## 📋 策略师 (strategist)',
121
+ content: `你是策略师。你的职责:
122
+ - 综合技术分析和宏观研究做出交易决策
123
+ - 管理整体仓位和风险敞口
124
+ - 协调团队分析方向
125
+
126
+ 风格:冷静理性,注重概率思维。`,
127
+ },
128
+ analyst: {
129
+ heading: '## 📊 技术分析师 (analyst)',
130
+ content: `你是技术分析师。你的职责:
131
+ - 分析价格走势和技术指标
132
+ - 识别关键支撑/阻力位
133
+ - 提供入场/出场信号
134
+
135
+ 风格:数据驱动,图表说话。`,
136
+ },
137
+ macro: {
138
+ heading: '## 🌐 宏观研究员 (macro)',
139
+ content: `你是宏观研究员。你的职责:
140
+ - 分析宏观经济数据和政策
141
+ - 评估市场情绪和资金流向
142
+ - 提供宏观背景判断
143
+
144
+ 风格:视野开阔,善于关联不同市场。`,
145
+ },
146
+ risk: {
147
+ heading: '## 🛡️ 风控 (risk)',
148
+ content: `你是风控。你的职责:
149
+ - 审查交易方案的风险
150
+ - 设定止损和仓位限制
151
+ - 监控已有持仓风险
152
+
153
+ 风格:保守谨慎,底线思维。`,
154
+ },
155
+ trader: {
156
+ heading: '## 💰 交易员 (trader)',
157
+ content: `你是交易员。你的职责:
158
+ - 执行策略师的交易决策
159
+ - 选择最优执行时机和方式
160
+ - 报告执行结果
161
+
162
+ 风格:执行力强,反应迅速。`,
163
+ },
164
+ // Video team
165
+ director: {
166
+ heading: '## 🎬 导演 (director)',
167
+ content: `你是导演。你的职责:
168
+ - 把控整体视觉叙事方向
169
+ - 审核脚本和分镜
170
+ - 做出最终创意决策
171
+
172
+ 风格:注重视觉叙事,善于把控节奏。`,
173
+ },
174
+ scriptwriter: {
175
+ heading: '## ✏️ 编剧 (scriptwriter)',
176
+ content: `你是编剧。你的职责:
177
+ - 撰写视频脚本和旁白
178
+ - 构建叙事结构
179
+ - 设计情节转折
180
+
181
+ 风格:善于讲故事,注重情感共鸣。`,
182
+ },
183
+ storyboard: {
184
+ heading: '## 🖼️ 分镜师 (storyboard)',
185
+ content: `你是分镜师。你的职责:
186
+ - 将脚本转化为视觉分镜
187
+ - 设计镜头语言和转场
188
+ - 确保视觉连贯性
189
+
190
+ 风格:视觉思维,善于用画面讲故事。`,
191
+ },
192
+ },
193
+
194
+ routeProtocol: `# ROUTE 协议
195
+
196
+ 当一个角色完成工作需要交给另一个角色时,使用 ROUTE 块:
197
+
198
+ ---ROUTE---
199
+ to: {目标角色name}
200
+ summary: {交接内容摘要}
201
+ task: {任务ID}(可选)
202
+ taskTitle: {任务标题}(可选)
203
+ ---END_ROUTE---
204
+
205
+ 规则:
206
+ - \`to\` 必须是有效的角色 name,或 \`human\` 表示需要用户输入
207
+ - 一次可以输出多个 ROUTE 块
208
+ - ROUTE 块必须在角色输出的末尾
209
+ - 切换后必须完全以该角色的视角和人格思考和行动`,
210
+
211
+ workflowTitle: '# 工作流程',
212
+ devWorkflow: `1. **PM** 分析需求,拆分任务,确定验收标准
213
+ 2. **开发者** 实现代码(使用工具读写文件)
214
+ 3. **审查者** Code Review(不通过 → 返回开发者修复)
215
+ 4. **测试者** 运行测试 & 验证(有 bug → 返回开发者修复)
216
+ 5. **PM** 验收总结`,
217
+ genericWorkflow: '按角色顺序依次完成任务。',
218
+
219
+ projectPathTitle: '# 项目路径',
220
+
221
+ sessionMemory: '# Session 记忆',
222
+ sessionMemoryDefault: '_本 session 的工作记录、决策和待办事项。_',
223
+ },
224
+
225
+ 'en': {
226
+ // ── .roleplay/CLAUDE.md (shared level) ──
227
+ sharedTitle: '# RolePlay Shared Instructions',
228
+ projectPath: '# Project Path',
229
+ useAbsolutePath: 'Use this absolute path for all code operations.',
230
+ workMode: '# Work Mode',
231
+ workModeContent: `RolePlay is a single-process multi-role collaboration mode. One Claude instance plays different roles in sequence, switching via the ROUTE protocol.
232
+ Differences from Crew (multi-process, each role has its own Claude instance):
233
+ - All roles share the same context window
234
+ - Role switching is instant (no cross-process communication)
235
+ - Suitable for lightweight collaboration and rapid iteration`,
236
+ workConventions: '# Work Conventions',
237
+ workConventionsContent: `- Write documentation output to .roleplay/context/ directory
238
+ - Use absolute project path for code changes
239
+ - Each role focuses on its own responsibilities`,
240
+ crewRelation: '# Relationship with .crew',
241
+ crewRelationContent: `- .roleplay/context/ can read .crew/context/ content
242
+ - .crew shared memory and kanban are visible to RolePlay
243
+ - RolePlay does not modify anything in .crew/ (read-only)`,
244
+ sharedMemory: '# Shared Memory',
245
+ sharedMemoryDefault: '_Maintained by all sessions, recording project-level consensus and decisions._',
246
+
247
+ // ── .roleplay/roles/{session}/CLAUDE.md (session level) ──
248
+ sessionTitle: (name) => `# RolePlay Session: ${name}`,
249
+ teamTypeLabel: '# Team Type',
250
+ languageLabel: '# Language',
251
+ roleListTitle: '# Role List',
252
+
253
+ roleTemplates: {
254
+ pm: {
255
+ heading: '## 📋 PM-Jobs (pm)',
256
+ content: `You are PM-Jobs. Your responsibilities:
257
+ - Analyze user requirements and understand intent
258
+ - Break down requirements into executable tasks
259
+ - Define acceptance criteria
260
+ - Final acceptance of deliverables
261
+
262
+ Style: Concise, user-value focused, excellent at grasping the essence.`,
263
+ },
264
+ dev: {
265
+ heading: '## 💻 Dev-Torvalds (dev)',
266
+ content: `You are Dev-Torvalds. Your responsibilities:
267
+ - Design technical solutions and architecture
268
+ - Use tools (Read, Edit, Write, Bash) to implement code
269
+ - Ensure code quality and maintainability
270
+ - Fix issues raised by reviewer and tester
271
+
272
+ Style: Pursue clean, elegant code. Value performance and maintainability. No fluff, just code.`,
273
+ },
274
+ reviewer: {
275
+ heading: '## 🔍 Reviewer-Martin (reviewer)',
276
+ content: `You are Reviewer-Martin. Your responsibilities:
277
+ - Carefully review code changes from the developer
278
+ - Check: code style, naming conventions, architecture, edge cases, security
279
+ - If issues found, clearly point them out with fix suggestions
280
+ - When approved, explicitly say "LGTM"
281
+
282
+ Style: Strict but kind, focused on best practices, good at spotting potential issues.`,
283
+ },
284
+ tester: {
285
+ heading: '## 🧪 Tester-Beck (tester)',
286
+ content: `You are Tester-Beck. Your responsibilities:
287
+ - Use Bash tool to run tests
288
+ - Verify functionality works as expected
289
+ - Check edge cases and error handling
290
+ - If bugs found, clearly describe reproduction steps
291
+
292
+ Style: Test-driven thinking, good at finding edge cases, pursuing reliability.`,
293
+ },
294
+ designer: {
295
+ heading: '## 🎨 Designer-Rams (designer)',
296
+ content: `You are Designer-Rams. Your responsibilities:
297
+ - Design user interaction flows and page layouts
298
+ - Ensure visual consistency and user experience
299
+ - Deliver design specs for developer implementation
300
+
301
+ Style: Less but better, pursuing ultimate simplicity.`,
302
+ },
303
+ planner: {
304
+ heading: '## 📋 Planner (planner)',
305
+ content: `You are the Planner. Your responsibilities:
306
+ - Set overall creative direction and outline
307
+ - Review content quality and consistency
308
+ - Make key creative decisions
309
+
310
+ Style: Focus on story core, good at pacing control.`,
311
+ },
312
+ writer: {
313
+ heading: '## ✍️ Writer (writer)',
314
+ content: `You are the Writer. Your responsibilities:
315
+ - Write specific content based on the outline
316
+ - Maintain writing quality and style consistency
317
+ - Follow the designer's pacing plan
318
+
319
+ Style: Sharp writing, good at using details to move people.`,
320
+ },
321
+ editor: {
322
+ heading: '## 📝 Editor (editor)',
323
+ content: `You are the Editor. Your responsibilities:
324
+ - Review writing quality, logical consistency
325
+ - Check typos, grammar, punctuation
326
+ - Confirm content aligns with overall direction
327
+
328
+ Style: Rigorous and detailed, focused on readability.`,
329
+ },
330
+ strategist: {
331
+ heading: '## 📋 Strategist (strategist)',
332
+ content: `You are the Strategist. Your responsibilities:
333
+ - Synthesize technical analysis and macro research for trading decisions
334
+ - Manage overall positions and risk exposure
335
+ - Coordinate team analysis direction
336
+
337
+ Style: Calm and rational, probability-focused thinking.`,
338
+ },
339
+ analyst: {
340
+ heading: '## 📊 Technical Analyst (analyst)',
341
+ content: `You are the Technical Analyst. Your responsibilities:
342
+ - Analyze price trends and technical indicators
343
+ - Identify key support/resistance levels
344
+ - Provide entry/exit signals
345
+
346
+ Style: Data-driven, charts speak.`,
347
+ },
348
+ macro: {
349
+ heading: '## 🌐 Macro Researcher (macro)',
350
+ content: `You are the Macro Researcher. Your responsibilities:
351
+ - Analyze macroeconomic data and policies
352
+ - Assess market sentiment and fund flows
353
+ - Provide macro context judgment
354
+
355
+ Style: Broad perspective, good at connecting different markets.`,
356
+ },
357
+ risk: {
358
+ heading: '## 🛡️ Risk Manager (risk)',
359
+ content: `You are the Risk Manager. Your responsibilities:
360
+ - Review trading plan risks
361
+ - Set stop-loss and position limits
362
+ - Monitor existing position risk
363
+
364
+ Style: Conservative, bottom-line thinking.`,
365
+ },
366
+ trader: {
367
+ heading: '## 💰 Trader (trader)',
368
+ content: `You are the Trader. Your responsibilities:
369
+ - Execute strategist's trading decisions
370
+ - Choose optimal execution timing and method
371
+ - Report execution results
372
+
373
+ Style: Strong execution, quick reactions.`,
374
+ },
375
+ director: {
376
+ heading: '## 🎬 Director (director)',
377
+ content: `You are the Director. Your responsibilities:
378
+ - Control overall visual narrative direction
379
+ - Review scripts and storyboards
380
+ - Make final creative decisions
381
+
382
+ Style: Visual storytelling, good at pacing control.`,
383
+ },
384
+ scriptwriter: {
385
+ heading: '## ✏️ Scriptwriter (scriptwriter)',
386
+ content: `You are the Scriptwriter. Your responsibilities:
387
+ - Write video scripts and narration
388
+ - Build narrative structure
389
+ - Design plot twists
390
+
391
+ Style: Good storytelling, focused on emotional resonance.`,
392
+ },
393
+ storyboard: {
394
+ heading: '## 🖼️ Storyboard Artist (storyboard)',
395
+ content: `You are the Storyboard Artist. Your responsibilities:
396
+ - Convert scripts into visual storyboards
397
+ - Design camera language and transitions
398
+ - Ensure visual continuity
399
+
400
+ Style: Visual thinking, good at telling stories with images.`,
401
+ },
402
+ },
403
+
404
+ routeProtocol: `# ROUTE Protocol
405
+
406
+ When a role finishes work and needs to hand off to another role, use a ROUTE block:
407
+
408
+ ---ROUTE---
409
+ to: {target_role_name}
410
+ summary: {handoff content summary}
411
+ task: {task ID} (optional)
412
+ taskTitle: {task title} (optional)
413
+ ---END_ROUTE---
414
+
415
+ Rules:
416
+ - \`to\` must be a valid role name, or \`human\` for user input
417
+ - Multiple ROUTE blocks can be output at once
418
+ - ROUTE blocks must be at the end of role output
419
+ - After switching, fully think and act from that role's perspective`,
420
+
421
+ workflowTitle: '# Workflow',
422
+ devWorkflow: `1. **PM** analyzes requirements, breaks down tasks, defines acceptance criteria
423
+ 2. **Dev** implements code (using tools to read/write files)
424
+ 3. **Reviewer** code review (if fails → back to Dev)
425
+ 4. **Tester** runs tests & verifies (if bugs → back to Dev)
426
+ 5. **PM** acceptance & summary`,
427
+ genericWorkflow: 'Complete tasks by following the role sequence.',
428
+
429
+ projectPathTitle: '# Project Path',
430
+
431
+ sessionMemory: '# Session Memory',
432
+ sessionMemoryDefault: '_Work records, decisions, and to-do items for this session._',
433
+ }
434
+ };
435
+
436
+ /**
437
+ * Get RolePlay i18n messages for the given language.
438
+ * Falls back to 'zh-CN' for unknown languages.
439
+ *
440
+ * @param {string} language
441
+ * @returns {object}
442
+ */
443
+ export function getRolePlayMessages(language) {
444
+ return messages[language] || messages['zh-CN'];
445
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * RolePlay Session — session index management for .roleplay/session.json.
3
+ *
4
+ * Analogous to agent/crew/session.js but much simpler:
5
+ * - No multi-process concerns (single process)
6
+ * - No worktree management
7
+ * - Atomic write for crash safety
8
+ *
9
+ * session.json schema:
10
+ * {
11
+ * sessions: [{ name, teamType, language, projectDir, conversationId,
12
+ * roles, createdAt, updatedAt, status }],
13
+ * activeSession: string | null
14
+ * }
15
+ */
16
+ import { promises as fs } from 'fs';
17
+ import { join } from 'path';
18
+ import { existsSync, readFileSync, writeFileSync, renameSync } from 'fs';
19
+
20
+ const SESSION_FILE = 'session.json';
21
+
22
+ // ─── Read / Write ───────────────────────────────────────────────────
23
+
24
+ /**
25
+ * Load .roleplay/session.json.
26
+ * Returns { sessions: [], activeSession: null } if file doesn't exist or is corrupt.
27
+ *
28
+ * @param {string} projectDir
29
+ * @returns {{ sessions: Array, activeSession: string|null }}
30
+ */
31
+ export function loadRolePlaySessionIndex(projectDir) {
32
+ const filePath = join(projectDir, '.roleplay', SESSION_FILE);
33
+ if (!existsSync(filePath)) {
34
+ return { sessions: [], activeSession: null };
35
+ }
36
+ try {
37
+ const raw = readFileSync(filePath, 'utf-8');
38
+ const data = JSON.parse(raw);
39
+ return {
40
+ sessions: Array.isArray(data.sessions) ? data.sessions : [],
41
+ activeSession: data.activeSession || null,
42
+ };
43
+ } catch (e) {
44
+ console.warn('[RolePlaySession] Failed to load session.json:', e.message);
45
+ return { sessions: [], activeSession: null };
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Save .roleplay/session.json atomically (write tmp → rename).
51
+ *
52
+ * @param {string} projectDir
53
+ * @param {{ sessions: Array, activeSession: string|null }} data
54
+ */
55
+ export async function saveRolePlaySessionIndex(projectDir, data) {
56
+ const rpDir = join(projectDir, '.roleplay');
57
+ const filePath = join(rpDir, SESSION_FILE);
58
+ const tmpPath = `${filePath}.tmp.${Date.now()}`;
59
+
60
+ const content = JSON.stringify(data, null, 2);
61
+ try {
62
+ await fs.mkdir(rpDir, { recursive: true });
63
+ await fs.writeFile(tmpPath, content);
64
+ await fs.rename(tmpPath, filePath);
65
+ } catch (e) {
66
+ console.error('[RolePlaySession] Failed to save session.json:', e.message);
67
+ // Clean up tmp file if rename failed
68
+ try { await fs.unlink(tmpPath); } catch {}
69
+ throw e;
70
+ }
71
+ }
72
+
73
+ // ─── Session CRUD ───────────────────────────────────────────────────
74
+
75
+ /**
76
+ * Add a new session entry to .roleplay/session.json.
77
+ * Sets it as the activeSession.
78
+ *
79
+ * @param {string} projectDir
80
+ * @param {object} session - session entry to add:
81
+ * { name, teamType, language, projectDir, conversationId, roles, createdAt }
82
+ * @returns {object} the session entry with updatedAt/status fields added
83
+ */
84
+ export async function addRolePlaySession(projectDir, session) {
85
+ const index = loadRolePlaySessionIndex(projectDir);
86
+
87
+ // Check for duplicate session name
88
+ const existing = index.sessions.find(s => s.name === session.name);
89
+ if (existing) {
90
+ // Update existing entry instead of creating duplicate
91
+ Object.assign(existing, {
92
+ ...session,
93
+ updatedAt: Date.now(),
94
+ status: 'active',
95
+ });
96
+ index.activeSession = session.name;
97
+ await saveRolePlaySessionIndex(projectDir, index);
98
+ return existing;
99
+ }
100
+
101
+ const entry = {
102
+ ...session,
103
+ updatedAt: Date.now(),
104
+ status: 'active',
105
+ };
106
+
107
+ index.sessions.push(entry);
108
+ index.activeSession = session.name;
109
+ await saveRolePlaySessionIndex(projectDir, index);
110
+ return entry;
111
+ }
112
+
113
+ /**
114
+ * Remove (archive) a session from .roleplay/session.json.
115
+ * The session entry is marked as 'archived', not deleted,
116
+ * so the context/ directory content remains valid.
117
+ *
118
+ * Optionally removes the session's roles/ directory.
119
+ *
120
+ * @param {string} projectDir
121
+ * @param {string} sessionName
122
+ * @param {object} [options]
123
+ * @param {boolean} [options.removeDir=true] - remove .roleplay/roles/{sessionName}/
124
+ */
125
+ export async function removeRolePlaySession(projectDir, sessionName, options = {}) {
126
+ const { removeDir = true } = options;
127
+ const index = loadRolePlaySessionIndex(projectDir);
128
+
129
+ const session = index.sessions.find(s => s.name === sessionName);
130
+ if (session) {
131
+ session.status = 'archived';
132
+ session.updatedAt = Date.now();
133
+ }
134
+
135
+ // Clear activeSession if it was the removed one
136
+ if (index.activeSession === sessionName) {
137
+ // Pick next active session (most recently updated non-archived)
138
+ const active = index.sessions
139
+ .filter(s => s.status === 'active')
140
+ .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
141
+ index.activeSession = active[0]?.name || null;
142
+ }
143
+
144
+ await saveRolePlaySessionIndex(projectDir, index);
145
+
146
+ // Remove the session directory (but keep context/)
147
+ if (removeDir) {
148
+ const sessionDir = join(projectDir, '.roleplay', 'roles', sessionName);
149
+ try {
150
+ await fs.rm(sessionDir, { recursive: true, force: true });
151
+ } catch {
152
+ // Not critical — may not exist
153
+ }
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Find a session entry by conversationId.
159
+ *
160
+ * @param {string} projectDir
161
+ * @param {string} conversationId
162
+ * @returns {object|null} session entry or null
163
+ */
164
+ export function findRolePlaySessionByConversationId(projectDir, conversationId) {
165
+ if (!conversationId) return null;
166
+ const index = loadRolePlaySessionIndex(projectDir);
167
+ return index.sessions.find(s => s.conversationId === conversationId) || null;
168
+ }
169
+
170
+ /**
171
+ * Update the activeSession pointer and updatedAt timestamp.
172
+ *
173
+ * @param {string} projectDir
174
+ * @param {string} sessionName
175
+ */
176
+ export async function setActiveRolePlaySession(projectDir, sessionName) {
177
+ const index = loadRolePlaySessionIndex(projectDir);
178
+ const session = index.sessions.find(s => s.name === sessionName);
179
+ if (session) {
180
+ session.updatedAt = Date.now();
181
+ index.activeSession = sessionName;
182
+ await saveRolePlaySessionIndex(projectDir, index);
183
+ }
184
+ }