@yeaft/webchat-agent 1.0.79 → 1.0.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.
@@ -1,295 +0,0 @@
1
- /**
2
- * Crew — 持久化管理
3
- * Session 索引 (~/.claude/crew-sessions.json)、session 元数据、消息分片
4
- */
5
- import { promises as fs } from 'fs';
6
- import { join } from 'path';
7
- import { homedir } from 'os';
8
-
9
- // =====================================================================
10
- // Crew Session Index (~/.claude/crew-sessions.json)
11
- // =====================================================================
12
-
13
- const CREW_INDEX_PATH = join(homedir(), '.claude', 'crew-sessions.json');
14
-
15
- // 写入锁:防止并发写入导致文件损坏
16
- let _indexWriteLock = Promise.resolve();
17
-
18
- export async function loadCrewIndex() {
19
- try { return JSON.parse(await fs.readFile(CREW_INDEX_PATH, 'utf-8')); }
20
- catch { return []; }
21
- }
22
-
23
- async function saveCrewIndex(index) {
24
- const doWrite = async () => {
25
- await fs.mkdir(join(homedir(), '.claude'), { recursive: true });
26
- const data = JSON.stringify(index, null, 2);
27
- // 先写临时文件再 rename,保证原子性
28
- const tmpPath = CREW_INDEX_PATH + '.tmp';
29
- await fs.writeFile(tmpPath, data);
30
- await fs.rename(tmpPath, CREW_INDEX_PATH);
31
- };
32
- // 串行化写入
33
- _indexWriteLock = _indexWriteLock.then(doWrite, doWrite);
34
- return _indexWriteLock;
35
- }
36
-
37
- function sessionToIndexEntry(session) {
38
- return {
39
- sessionId: session.id,
40
- projectDir: session.projectDir,
41
- sharedDir: session.sharedDir,
42
- status: session.status,
43
- name: session.name || '',
44
- userId: session.userId,
45
- username: session.username,
46
- agentId: session.agentId || null,
47
- createdAt: session.createdAt,
48
- updatedAt: Date.now()
49
- };
50
- }
51
-
52
- export async function upsertCrewIndex(session) {
53
- const index = await loadCrewIndex();
54
- const entry = sessionToIndexEntry(session);
55
- const idx = index.findIndex(e => e.sessionId === session.id);
56
- if (idx >= 0) index[idx] = entry; else index.push(entry);
57
- await saveCrewIndex(index);
58
- }
59
-
60
- export async function removeFromCrewIndex(sessionId) {
61
- // Lazy import to avoid circular dependency
62
- const { crewSessions } = await import('./session.js');
63
-
64
- const index = await loadCrewIndex();
65
- const filtered = index.filter(e => e.sessionId !== sessionId);
66
- if (filtered.length !== index.length) {
67
- await saveCrewIndex(filtered);
68
- console.log(`[Crew] Removed session ${sessionId} from index`);
69
- }
70
- // 从内存中也移除(防止 sendConversationList 重新加入)
71
- if (crewSessions.has(sessionId)) {
72
- crewSessions.delete(sessionId);
73
- console.log(`[Crew] Removed session ${sessionId} from active sessions`);
74
- }
75
- // 注意:不再删除磁盘上的 session.json、messages.json 等文件
76
- // 这些文件在 recreate 时会被复用(合并统计数据 + 恢复消息历史)
77
- }
78
-
79
- // =====================================================================
80
- // Session Metadata (.crew/session.json)
81
- // =====================================================================
82
-
83
- const MESSAGE_SHARD_SIZE = 256 * 1024; // 256KB per shard
84
- const SAVE_DEBOUNCE_MS = 10_000; // 10 seconds
85
-
86
- /**
87
- * Debounced version of saveSessionMeta.
88
- * Coalesces rapid-fire calls (e.g. multiple role turn-ends) into a single
89
- * disk write per session, at most once every SAVE_DEBOUNCE_MS.
90
- */
91
- export function debouncedSaveSessionMeta(session) {
92
- if (session._saveDebounceTimer) {
93
- clearTimeout(session._saveDebounceTimer);
94
- }
95
- session._saveDebounceTimer = setTimeout(() => {
96
- session._saveDebounceTimer = null;
97
- saveSessionMeta(session).catch(e =>
98
- console.warn('[Crew] Debounced save failed:', e.message)
99
- );
100
- }, SAVE_DEBOUNCE_MS);
101
- }
102
-
103
- export async function saveSessionMeta(session) {
104
- const meta = {
105
- sessionId: session.id,
106
- projectDir: session.projectDir,
107
- sharedDir: session.sharedDir,
108
- name: session.name || '',
109
- status: session.status,
110
- // claudeMd is intentionally excluded — it's large and already persisted
111
- // in per-role CLAUDE.md files under .crew/roles/<name>/CLAUDE.md
112
- roles: Array.from(session.roles.values()).map(r => ({
113
- name: r.name, displayName: r.displayName, icon: r.icon,
114
- description: r.description,
115
- isDecisionMaker: r.isDecisionMaker || false,
116
- groupIndex: r.groupIndex, roleType: r.roleType, model: r.model
117
- })),
118
- decisionMaker: session.decisionMaker,
119
- round: session.round,
120
- createdAt: session.createdAt,
121
- updatedAt: Date.now(),
122
- userId: session.userId,
123
- username: session.username,
124
- agentId: session.agentId || null,
125
- teamType: session.teamType || 'dev',
126
- language: session.language || 'zh-CN',
127
- costUsd: session.costUsd,
128
- totalInputTokens: session.totalInputTokens,
129
- totalOutputTokens: session.totalOutputTokens,
130
- features: Array.from(session.features.values()),
131
- _completedTaskIds: Array.from(session._completedTaskIds || [])
132
- };
133
- await fs.writeFile(join(session.sharedDir, 'session.json'), JSON.stringify(meta, null, 2));
134
- // 保存 UI 消息历史(用于恢复时重放)
135
- if (session.uiMessages && session.uiMessages.length > 0) {
136
- // 清理 _streaming 标记后保存
137
- const cleaned = session.uiMessages.map(m => {
138
- const { _streaming, ...rest } = m;
139
- return rest;
140
- });
141
- const json = JSON.stringify(cleaned);
142
- // 超过阈值时直接归档(rotateMessages 内部写两个文件,避免双写)
143
- if (json.length > MESSAGE_SHARD_SIZE && !session._rotating) {
144
- await rotateMessages(session, cleaned);
145
- } else {
146
- await fs.writeFile(join(session.sharedDir, 'messages.json'), json);
147
- }
148
- }
149
- }
150
-
151
- /**
152
- * 归档旧消息到分片文件(logrotate 风格)
153
- */
154
- async function rotateMessages(session, cleaned) {
155
- session._rotating = true;
156
- try {
157
- const halfLen = Math.floor(cleaned.length / 2);
158
- let splitIdx = halfLen;
159
- for (let i = halfLen; i > Math.max(0, halfLen - 20); i--) {
160
- if (cleaned[i].type === 'route' || cleaned[i].type === 'system') {
161
- splitIdx = i + 1;
162
- break;
163
- }
164
- }
165
- if (splitIdx === halfLen) {
166
- for (let i = halfLen + 1; i < Math.min(cleaned.length - 1, halfLen + 20); i++) {
167
- if (cleaned[i].type === 'route' || cleaned[i].type === 'system') {
168
- splitIdx = i + 1;
169
- break;
170
- }
171
- }
172
- }
173
- splitIdx = Math.max(1, Math.min(splitIdx, cleaned.length - 1));
174
-
175
- const archivePart = cleaned.slice(0, splitIdx);
176
- const remainPart = cleaned.slice(splitIdx);
177
-
178
- const maxShard = await getMaxShardIndex(session.sharedDir);
179
- for (let i = maxShard; i >= 1; i--) {
180
- const src = join(session.sharedDir, `messages.${i}.json`);
181
- const dst = join(session.sharedDir, `messages.${i + 1}.json`);
182
- await fs.rename(src, dst).catch(() => {});
183
- }
184
-
185
- await fs.writeFile(join(session.sharedDir, 'messages.1.json'), JSON.stringify(archivePart));
186
- await fs.writeFile(join(session.sharedDir, 'messages.json'), JSON.stringify(remainPart));
187
- session.uiMessages = remainPart.map(m => ({ ...m }));
188
-
189
- console.log(`[Crew] Rotated messages: archived ${archivePart.length} msgs to shard 1, kept ${remainPart.length} in active`);
190
- } finally {
191
- session._rotating = false;
192
- }
193
- }
194
-
195
- /**
196
- * 获取当前最大分片编号
197
- */
198
- export async function getMaxShardIndex(sharedDir) {
199
- let max = 0;
200
- try {
201
- const files = await fs.readdir(sharedDir);
202
- for (const f of files) {
203
- const match = f.match(/^messages\.(\d+)\.json$/);
204
- if (match) {
205
- const idx = parseInt(match[1], 10);
206
- if (idx > max) max = idx;
207
- }
208
- }
209
- } catch { /* dir may not exist */ }
210
- return max;
211
- }
212
-
213
- /**
214
- * 删除所有消息分片文件
215
- */
216
- export async function cleanupMessageShards(sharedDir) {
217
- try {
218
- const files = await fs.readdir(sharedDir);
219
- for (const f of files) {
220
- if (/^messages\.\d+\.json$/.test(f)) {
221
- await fs.unlink(join(sharedDir, f)).catch(() => {});
222
- }
223
- }
224
- } catch { /* dir may not exist */ }
225
- }
226
-
227
- export async function loadSessionMeta(sharedDir) {
228
- try { return JSON.parse(await fs.readFile(join(sharedDir, 'session.json'), 'utf-8')); }
229
- catch { return null; }
230
- }
231
-
232
- export async function loadSessionMessages(sharedDir) {
233
- let messages = [];
234
- try { messages = JSON.parse(await fs.readFile(join(sharedDir, 'messages.json'), 'utf-8')); }
235
- catch { /* file may not exist */ }
236
- let hasOlderMessages = false;
237
- try {
238
- await fs.access(join(sharedDir, 'messages.1.json'));
239
- hasOlderMessages = true;
240
- } catch { /* no older shards */ }
241
- return { messages, hasOlderMessages };
242
- }
243
-
244
- /**
245
- * 加载历史消息分片(前端上滑到顶部时按需请求)
246
- */
247
- export async function handleLoadCrewHistory(msg) {
248
- const { sessionId, requestId } = msg;
249
- const shardIndex = parseInt(msg.shardIndex, 10);
250
-
251
- // Lazy import to avoid circular dependency
252
- const { crewSessions } = await import('./session.js');
253
- const { sendCrewMessage } = await import('./ui-messages.js');
254
-
255
- if (!Number.isFinite(shardIndex) || shardIndex < 1) {
256
- sendCrewMessage({
257
- type: 'crew_history_loaded',
258
- sessionId,
259
- shardIndex: msg.shardIndex,
260
- requestId,
261
- messages: [],
262
- hasMore: false
263
- });
264
- return;
265
- }
266
- const session = crewSessions.get(sessionId);
267
- if (!session) {
268
- sendCrewMessage({
269
- type: 'crew_history_loaded',
270
- sessionId,
271
- shardIndex,
272
- requestId,
273
- messages: [],
274
- hasMore: false
275
- });
276
- return;
277
- }
278
-
279
- const shardPath = join(session.sharedDir, `messages.${shardIndex}.json`);
280
- let messages = [];
281
- try {
282
- messages = JSON.parse(await fs.readFile(shardPath, 'utf-8'));
283
- } catch { /* shard file doesn't exist */ }
284
-
285
- const hasMore = shardIndex < await getMaxShardIndex(session.sharedDir);
286
-
287
- sendCrewMessage({
288
- type: 'crew_history_loaded',
289
- sessionId,
290
- shardIndex,
291
- requestId,
292
- messages,
293
- hasMore
294
- });
295
- }
@@ -1,182 +0,0 @@
1
- /**
2
- * Crew — 动态角色管理
3
- * addRoleToSession, removeRoleFromSession
4
- */
5
- import { initRoleDir, updateSharedClaudeMd } from './shared-dir.js';
6
- import { saveRoleSessionId } from './role-query.js';
7
- import { sendCrewMessage, sendCrewOutput, sendStatusUpdate } from './ui-messages.js';
8
- import { saveSessionMeta, upsertCrewIndex } from './persistence.js';
9
-
10
- /** Format role label */
11
- function roleLabel(r) {
12
- return r.icon ? `${r.icon} ${r.displayName}` : r.displayName;
13
- }
14
-
15
- /**
16
- * 向现有 session 动态添加角色
17
- */
18
- export async function addRoleToSession(msg) {
19
- // Lazy import to avoid circular dependency
20
- const { crewSessions, expandRoles, resumeCrewSession } = await import('./session.js');
21
-
22
- const { sessionId, role } = msg;
23
- let session = crewSessions.get(sessionId);
24
- if (!session) {
25
- // Auto-resume: try to restore from disk before giving up
26
- console.log(`[Crew] Session ${sessionId} not in memory, attempting auto-resume...`);
27
- try {
28
- await resumeCrewSession({ sessionId });
29
- session = crewSessions.get(sessionId);
30
- } catch (e) {
31
- console.warn(`[Crew] Auto-resume failed for ${sessionId}:`, e.message);
32
- }
33
- if (!session) {
34
- console.warn(`[Crew] Session not found: ${sessionId} (even after auto-resume)`);
35
- return;
36
- }
37
- }
38
-
39
- const rolesToAdd = expandRoles([role]);
40
- const addedRoles = [];
41
-
42
- for (const r of rolesToAdd) {
43
- if (session.roles.has(r.name)) {
44
- console.warn(`[Crew] Role already exists: ${r.name}`);
45
- continue;
46
- }
47
-
48
- session.roles.set(r.name, r);
49
- addedRoles.push(r);
50
-
51
- if (r.isDecisionMaker) {
52
- session.decisionMaker = r.name;
53
- }
54
- if (!session.decisionMaker) {
55
- session.decisionMaker = r.name;
56
- }
57
-
58
- await initRoleDir(session.sharedDir, r, session.language || 'zh-CN', Array.from(session.roles.values()));
59
-
60
- console.log(`[Crew] Role added: ${r.name} (${r.displayName}) to session ${sessionId}`);
61
-
62
- sendCrewMessage({
63
- type: 'crew_role_added',
64
- sessionId,
65
- role: {
66
- name: r.name,
67
- displayName: r.displayName,
68
- icon: r.icon,
69
- description: r.description,
70
- isDecisionMaker: r.isDecisionMaker || false,
71
- model: r.model,
72
- roleType: r.roleType,
73
- groupIndex: r.groupIndex
74
- },
75
- decisionMaker: session.decisionMaker
76
- });
77
-
78
- sendCrewOutput(session, 'system', 'system', {
79
- type: 'assistant',
80
- message: { role: 'assistant', content: [{ type: 'text', text: `${roleLabel(r)} 加入了群聊` }] }
81
- });
82
- }
83
-
84
- await updateSharedClaudeMd(session);
85
-
86
- // Notify the decision maker about the new role(s) so it can ROUTE to them.
87
- // We inject a system message into the DM's next turn via dispatchToRole
88
- // (only if the DM is idle — otherwise the DM will see the updated team list
89
- // through its CLAUDE.md or recent-routes context on next turn).
90
- if (session.decisionMaker && addedRoles.length > 0) {
91
- const dmState = session.roleStates.get(session.decisionMaker);
92
- const dmIdle = !dmState || !dmState.turnActive;
93
-
94
- if (dmIdle) {
95
- const isZh = (session.language || 'zh-CN').startsWith('zh');
96
- const addedNames = addedRoles.map(r => `${roleLabel(r)}(${r.name})`).join(', ');
97
- const notice = isZh
98
- ? `[系统通知] 新角色已加入团队: ${addedNames}。请在后续任务分配中考虑这些新成员。`
99
- : `[System Notice] New role(s) joined the team: ${addedNames}. Consider them in future task assignments.`;
100
-
101
- // Lazy import to avoid circular dependency
102
- const { dispatchToRole } = await import('./routing.js');
103
- await dispatchToRole(session, session.decisionMaker, notice, 'system');
104
- }
105
- }
106
-
107
- sendStatusUpdate(session);
108
- // Roles changed — persist
109
- saveSessionMeta(session).catch(e => console.warn('[Crew] Failed to save after addRole:', e.message));
110
- upsertCrewIndex(session).catch(e => console.warn('[Crew] Failed to update index after addRole:', e.message));
111
- }
112
-
113
- /**
114
- * 从 session 移除角色
115
- */
116
- export async function removeRoleFromSession(msg) {
117
- const { crewSessions, resumeCrewSession } = await import('./session.js');
118
-
119
- const { sessionId, roleName } = msg;
120
- let session = crewSessions.get(sessionId);
121
- if (!session) {
122
- // Auto-resume: try to restore from disk before giving up
123
- console.log(`[Crew] Session ${sessionId} not in memory, attempting auto-resume...`);
124
- try {
125
- await resumeCrewSession({ sessionId });
126
- session = crewSessions.get(sessionId);
127
- } catch (e) {
128
- console.warn(`[Crew] Auto-resume failed for ${sessionId}:`, e.message);
129
- }
130
- if (!session) {
131
- console.warn(`[Crew] Session not found: ${sessionId} (even after auto-resume)`);
132
- return;
133
- }
134
- }
135
-
136
- const role = session.roles.get(roleName);
137
- if (!role) {
138
- console.warn(`[Crew] Role not found: ${roleName}`);
139
- return;
140
- }
141
-
142
- // 停止角色的 query
143
- const roleState = session.roleStates.get(roleName);
144
- if (roleState) {
145
- if (roleState.claudeSessionId) {
146
- await saveRoleSessionId(session.sharedDir, roleName, roleState.claudeSessionId);
147
- }
148
- if (roleState.abortController) {
149
- roleState.abortController.abort();
150
- }
151
- session.roleStates.delete(roleName);
152
- }
153
-
154
- session.roles.delete(roleName);
155
-
156
- if (session.decisionMaker === roleName) {
157
- const remaining = Array.from(session.roles.values());
158
- const newDM = remaining.find(r => r.isDecisionMaker) || remaining[0];
159
- session.decisionMaker = newDM?.name || null;
160
- }
161
-
162
- await updateSharedClaudeMd(session);
163
-
164
- console.log(`[Crew] Role removed: ${roleName} from session ${sessionId}`);
165
-
166
- sendCrewMessage({
167
- type: 'crew_role_removed',
168
- sessionId,
169
- roleName,
170
- decisionMaker: session.decisionMaker
171
- });
172
-
173
- sendCrewOutput(session, 'system', 'system', {
174
- type: 'assistant',
175
- message: { role: 'assistant', content: [{ type: 'text', text: `${roleLabel(role)} 离开了群聊` }] }
176
- });
177
-
178
- sendStatusUpdate(session);
179
- // Roles changed — persist
180
- saveSessionMeta(session).catch(e => console.warn('[Crew] Failed to save after removeRole:', e.message));
181
- upsertCrewIndex(session).catch(e => console.warn('[Crew] Failed to update index after removeRole:', e.message));
182
- }