@yeaft/webchat-agent 1.0.76 → 1.0.78

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.
@@ -8,14 +8,24 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
8
8
  import { join } from 'path';
9
9
  import { parseMessage, parseSeqFromId } from './persist.js';
10
10
 
11
+ function parseJsonLine(line) {
12
+ if (!line || !line.trim()) return null;
13
+ try {
14
+ const msg = JSON.parse(line);
15
+ return msg && typeof msg === 'object' ? msg : null;
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
11
21
  /**
12
- * Search messages in a directory for a keyword.
22
+ * Search Markdown messages in a directory for a keyword.
13
23
  *
14
24
  * @param {string} dir — messages directory
15
25
  * @param {string} keyword — search term (case-insensitive)
16
26
  * @returns {object[]} — matching messages
17
27
  */
18
- function searchDir(dir, keyword) {
28
+ function searchMarkdownDir(dir, keyword) {
19
29
  if (!existsSync(dir)) return [];
20
30
 
21
31
  const lowerKeyword = keyword.toLowerCase();
@@ -35,6 +45,29 @@ function searchDir(dir, keyword) {
35
45
  return results;
36
46
  }
37
47
 
48
+ function searchSegmentDir(dir, keyword) {
49
+ if (!existsSync(dir)) return [];
50
+ const lowerKeyword = keyword.toLowerCase();
51
+ const files = readdirSync(dir)
52
+ .filter(f => f.endsWith('.jsonl'))
53
+ .sort()
54
+ .reverse();
55
+
56
+ const results = [];
57
+ for (const file of files) {
58
+ const raw = readFileSync(join(dir, file), 'utf8');
59
+ if (!raw.toLowerCase().includes(lowerKeyword)) continue;
60
+ const lines = raw.split('\n');
61
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
62
+ const line = lines[i];
63
+ if (!line || !line.toLowerCase().includes(lowerKeyword)) continue;
64
+ const msg = parseJsonLine(line);
65
+ if (msg) results.push(msg);
66
+ }
67
+ }
68
+ return results;
69
+ }
70
+
38
71
  function compareNewest(a, b) {
39
72
  const sa = parseSeqFromId(a?.id);
40
73
  const sb = parseSeqFromId(b?.id);
@@ -42,7 +75,7 @@ function compareNewest(a, b) {
42
75
  return String(b?.time || '').localeCompare(String(a?.time || ''));
43
76
  }
44
77
 
45
- function sessionConversationMessageDirs(dir) {
78
+ function sessionConversationDirs(dir) {
46
79
  const dirs = [];
47
80
  const seen = new Set();
48
81
  for (const rootName of ['sessions', 'groups']) {
@@ -57,12 +90,9 @@ function sessionConversationMessageDirs(dir) {
57
90
  }
58
91
 
59
92
  const conversationDir = join(sessionDir, 'conversation');
60
- for (const kind of ['messages', 'cold']) {
61
- const messagesDir = join(conversationDir, kind);
62
- if (seen.has(messagesDir)) continue;
63
- seen.add(messagesDir);
64
- dirs.push(messagesDir);
65
- }
93
+ if (seen.has(conversationDir)) continue;
94
+ seen.add(conversationDir);
95
+ dirs.push(conversationDir);
66
96
  }
67
97
  }
68
98
  return dirs;
@@ -79,17 +109,23 @@ function sessionConversationMessageDirs(dir) {
79
109
  export function searchMessages(dir, keyword, limit = 20) {
80
110
  if (!keyword || !keyword.trim()) return [];
81
111
 
82
- const dirs = [
83
- join(dir, 'chat', 'messages'),
84
- join(dir, 'chat', 'cold'),
85
- ...sessionConversationMessageDirs(dir),
112
+ const conversationDirs = [
113
+ join(dir, 'chat'),
114
+ ...sessionConversationDirs(dir),
115
+ ];
116
+
117
+ const markdownDirs = [
118
+ ...conversationDirs.flatMap(d => [join(d, 'messages'), join(d, 'cold')]),
86
119
  // Compatibility for profiles created before chat/session split.
87
120
  join(dir, 'conversation', 'messages'),
88
121
  join(dir, 'conversation', 'cold'),
89
122
  ];
123
+ const segmentDirs = conversationDirs.map(d => join(d, 'segments'));
90
124
 
91
- return dirs
92
- .flatMap(d => searchDir(d, keyword))
125
+ return [
126
+ ...segmentDirs.flatMap(d => searchSegmentDir(d, keyword)),
127
+ ...markdownDirs.flatMap(d => searchMarkdownDir(d, keyword)),
128
+ ]
93
129
  .sort(compareNewest)
94
130
  .slice(0, limit);
95
131
  }
package/yeaft/session.js CHANGED
@@ -45,7 +45,7 @@ import { TaskManager } from './tasks/manager.js';
45
45
  // resumes with the same onDemand/recent membership it had on
46
46
  // disconnect. Engine.#runQuery uses the registry to populate the
47
47
  // AMS each turn and to run `memory/adjust.js` post-turn.
48
- import { ensureDefaultSessionIfEmpty } from './sessions/session-crud.js';
48
+ import { ensureDefaultSessionIfEmpty, yeaftDirForWorkDir } from './sessions/session-crud.js';
49
49
  import { seedDefaultVps } from './vp/seed-defaults.js';
50
50
  import { topUpDefaultVps } from './vp/seed-topup.js';
51
51
  import { archiveLegacyScopes } from './memory/seed-backfill.js';
@@ -71,6 +71,7 @@ const DEFAULT_COMPACT_TRIGGER_RATIO = 0.7;
71
71
  /**
72
72
  * @typedef {Object} SessionOptions
73
73
  * @property {string} [dir] — Yeaft data directory override (default: ~/.yeaft)
74
+ * @property {string} [workDir] — Session workDir; when provided, storage lives under <workDir>/.yeaft while config still comes from dir/defaults
74
75
  * @property {string} [model] — Model override
75
76
  * @property {string} [language] — Language override ('en' | 'zh')
76
77
  * @property {boolean} [debug] — Debug mode override
@@ -136,6 +137,7 @@ function prepareToolStatsDir(yeaftDir) {
136
137
  export async function loadSession(options = {}) {
137
138
  const {
138
139
  dir,
140
+ workDir,
139
141
  model,
140
142
  language,
141
143
  debug,
@@ -146,21 +148,29 @@ export async function loadSession(options = {}) {
146
148
  serverMode = false,
147
149
  } = options;
148
150
 
149
- // ─── 1. Determine yeaftDir + ensure directory structure ──
150
- // Must happen BEFORE loadConfig so that first-run
151
- // generates a default config.json that loadConfig can read.
151
+ // ─── 1. Determine config + store directories ─────────────
152
+ // Must happen BEFORE loadConfig so that first-run generates a
153
+ // default config.json that loadConfig can read. workDir-backed
154
+ // Sessions store conversation/session data under <workDir>/.yeaft,
155
+ // but runtime config still comes from the agent-local configDir.
152
156
  const overrides = { ...configOverrides };
153
157
  if (dir) overrides.dir = dir;
154
158
  if (model) overrides.model = model;
155
159
  if (language) overrides.language = language;
156
160
  if (debug !== undefined) overrides.debug = debug;
157
161
 
158
- const yeaftDir = overrides.dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
159
- const initResult = initYeaftDir(yeaftDir);
160
- overrides.dir = yeaftDir;
161
-
162
- // Log any warnings from directory initialization
163
- for (const w of initResult.warnings) {
162
+ const sessionWorkDir = typeof workDir === 'string' && workDir.trim() ? workDir.trim() : '';
163
+ const configDir = overrides.dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
164
+ const yeaftDir = sessionWorkDir ? yeaftDirForWorkDir(sessionWorkDir) : configDir;
165
+ const configInitResult = initYeaftDir(configDir);
166
+ const storeInitResult = yeaftDir === configDir ? configInitResult : initYeaftDir(yeaftDir);
167
+ overrides.dir = configDir;
168
+
169
+ // Log any warnings from directory initialization.
170
+ const initWarnings = yeaftDir === configDir
171
+ ? configInitResult.warnings
172
+ : [...configInitResult.warnings, ...storeInitResult.warnings];
173
+ for (const w of initWarnings) {
164
174
  console.warn(`[Yeaft] ${w}`);
165
175
  }
166
176
 
@@ -212,7 +222,7 @@ export async function loadSession(options = {}) {
212
222
  // ─── 2a. Permission pre-check ─────────────────────────
213
223
  // If the data dir is not writable, mark session as read-only.
214
224
  // Persistence (conversation, memory, dream) is skipped in this mode.
215
- if (!initResult.writable) {
225
+ if (!storeInitResult.writable) {
216
226
  config._readOnly = true;
217
227
  console.warn(`[Yeaft] ${yeaftDir} is not writable — running in read-only mode`);
218
228
  }
@@ -365,24 +375,23 @@ export async function loadSession(options = {}) {
365
375
  }
366
376
 
367
377
  // ─── 6. Load skills ────────────────────────────────────
368
- // Project tier root for skills + MCP project assets. Per-session workDir
369
- // overlays are loaded by web-bridge once it knows the selected Session meta;
370
- // the base runtime still uses the agent process cwd for global/default status.
371
- const projectTierRoot = process.cwd();
378
+ // User/global runtime assets still come from configDir. workDir, when
379
+ // present, is only a project tier overlay plus the storage root.
380
+ const projectTierRoot = sessionWorkDir || process.cwd();
372
381
 
373
382
  let skillManager;
374
383
  if (skipSkills) {
375
384
  // Pass the literal user-tier dir (matches the normal branch's tier 2)
376
385
  // so any save/remove calls land in the same place users expect. New
377
386
  // `SkillManager` API takes literal scan dirs — no auto-suffix of /skills.
378
- skillManager = new SkillManager(join(yeaftDir, 'skills'));
387
+ skillManager = new SkillManager(join(configDir, 'skills'));
379
388
  // Don't call .load() — empty skill manager
380
389
  } else {
381
- skillManager = createSkillManager(yeaftDir, projectTierRoot);
390
+ skillManager = createSkillManager(configDir, projectTierRoot);
382
391
  }
383
392
 
384
393
  // ─── 7. Connect MCP servers ────────────────────────────
385
- const mcpConfig = loadMCPConfig(yeaftDir, undefined, projectTierRoot);
394
+ const mcpConfig = loadMCPConfig(configDir, undefined, projectTierRoot);
386
395
  const mcpManager = new MCPManager();
387
396
  let mcpStatus = { connected: [], failed: [] };
388
397
 
@@ -12,10 +12,7 @@
12
12
  *
13
13
  * task-330c lint guard:
14
14
  * ⚠️ DO NOT introduce greedy `text.replace(/---ROUTE---[\s\S]*$/g, '')`
15
- * style strips on incoming/outgoing message bodies. Crew ROUTE
16
- * stripping is owned EXCLUSIVELY by `agent/crew/routing.js`
17
- * `parseRoutes()` which returns `{routes, displayBody}` with exact
18
- * ranges removed.
15
+ * style strips on incoming/outgoing message bodies.
19
16
  */
20
17
 
21
18
  import { delimiter, join } from 'node:path';
@@ -53,6 +50,7 @@ import {
53
50
  scanWorkdirSessions,
54
51
  restoreSessionToRegistry,
55
52
  readWorkDirRegistry,
53
+ yeaftDirForWorkDir,
56
54
  } from './sessions/session-crud.js';
57
55
  import { openSession, loadSessionMeta } from './sessions/session-store.js';
58
56
  import { loadSessionConfig, resolveSessionConfig, SessionConfigError } from './sessions/session-config.js';
@@ -414,6 +412,13 @@ function projectRuntimeKey(workDir) {
414
412
  return normalizeSessionWorkDir(workDir) || '__agent_cwd__';
415
413
  }
416
414
 
415
+ function resolveStoreYeaftDirForSession(defaultYeaftDir, { sessionId = null, sessionMeta = null, workDir = '' } = {}) {
416
+ const normalizedWorkDir = normalizeSessionWorkDir(workDir || sessionMeta?.workDir);
417
+ if (normalizedWorkDir) return yeaftDirForWorkDir(normalizedWorkDir);
418
+ if (sessionId) return resolveSessionYeaftDir(defaultYeaftDir, sessionId);
419
+ return defaultYeaftDir;
420
+ }
421
+
417
422
  function createThreadId() {
418
423
  return `thr_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`;
419
424
  }
@@ -3583,7 +3588,7 @@ async function runYeaftSessionSend(msg) {
3583
3588
 
3584
3589
  // ── Attachments (images + files) ───────────────────────────────
3585
3590
  // Server has already resolved fileId → { name, mimeType, data:base64,
3586
- // isImage } via the same path crew uses (client-conversation.js relay
3591
+ // isImage } via the client-conversation.js relay
3587
3592
  // for `yeaft_*`). We persist files to disk under the agent's CWD so
3588
3593
  // file-tools (file-read / bash) can pick them up with relative paths,
3589
3594
  // and we build per-image content blocks for the LLM call. The
@@ -3880,6 +3885,11 @@ async function ensureSessionLoaded(opts = {}) {
3880
3885
  sessionLoadPromise = (async () => {
3881
3886
  const yeaftDir = ctx.CONFIG?.yeaftDir;
3882
3887
  const normalizedWorkDir = normalizeSessionWorkDir(opts?.workDir || opts?.sessionMeta?.workDir);
3888
+ const sessionYeaftDir = resolveStoreYeaftDirForSession(yeaftDir, {
3889
+ sessionId: opts?.sessionId || opts?.sessionMeta?.id || null,
3890
+ sessionMeta: opts?.sessionMeta || null,
3891
+ workDir: normalizedWorkDir,
3892
+ });
3883
3893
  session = await loadSession({
3884
3894
  ...(yeaftDir && { dir: yeaftDir }),
3885
3895
  ...(normalizedWorkDir && { workDir: normalizedWorkDir }),
@@ -5640,12 +5650,22 @@ export async function handleYeaftLoadHistory(msg) {
5640
5650
  const limit = (typeof msg.limit === 'number') ? msg.limit : 10;
5641
5651
  ensureYeaftConversationId();
5642
5652
 
5653
+ let sessionMetaForRuntime = null;
5654
+ let sessionYeaftDir = yeaftDir;
5655
+ if (sessionId) {
5656
+ try {
5657
+ sessionYeaftDir = resolveSessionYeaftDir(yeaftDir, sessionId);
5658
+ const metaDir = join(sessionsRoot(sessionYeaftDir), sessionId);
5659
+ sessionMetaForRuntime = loadSessionMeta(metaDir);
5660
+ } catch { /* best-effort metadata hint */ }
5661
+ }
5662
+
5643
5663
  // First paint must not wait for full Yeaft runtime boot (MCP connects,
5644
- // skill scans, memory index sync). The conversation markdown store is the
5664
+ // skill scans, memory index sync). The conversation segment store is the
5645
5665
  // source of truth and can be opened cheaply, so replay the visible message
5646
5666
  // window immediately, then finish loadSession below for actual turns.
5647
5667
  const coldStoreStart = perfNowMs();
5648
- const coldStore = new ConversationStore(yeaftDir);
5668
+ const coldStore = new ConversationStore(sessionYeaftDir);
5649
5669
  traceDuration('history.cold_store_open', coldStoreStart);
5650
5670
  if (sessionId && (afterSeqRaw !== null || afterMessageId)) {
5651
5671
  let afterSeq = afterSeqRaw;
@@ -5675,14 +5695,6 @@ export async function handleYeaftLoadHistory(msg) {
5675
5695
  }
5676
5696
  historyAlreadyReplayed = true;
5677
5697
 
5678
- let sessionMetaForRuntime = null;
5679
- if (sessionId) {
5680
- try {
5681
- const groupYeaftDir = resolveSessionYeaftDir(yeaftDir, sessionId);
5682
- const metaDir = join(sessionsRoot(groupYeaftDir), sessionId);
5683
- sessionMetaForRuntime = loadSessionMeta(metaDir);
5684
- } catch { /* best-effort metadata hint */ }
5685
- }
5686
5698
  // Full runtime boot can be expensive (memory FTS sync, skills, MCP, dream
5687
5699
  // boot checks). It is not needed to render persisted history, so keep this
5688
5700
  // request short and let message-send await the same single-flight boot when
@@ -1,154 +0,0 @@
1
- /**
2
- * Crew — built-in role actions (task-330a §B)
3
- *
4
- * These two actions replace the "PM 给自己发闭环消息" anti-pattern that
5
- * task-330a §A now rejects at the routing layer:
6
- *
7
- * • taskClose(session, { taskId, summary, fromRole })
8
- * — directly mark the task complete on the kanban + broadcast a
9
- * status card via sendCrewMessage. No ROUTE round-trip.
10
- *
11
- * • roleStandby(session, { role, reason, fromRole })
12
- * — flip a role's runtime state to 'standby' and broadcast.
13
- * Persistence to .crew/context/role-states.json is a 330d
14
- * concern — we only define the in-memory contract here.
15
- *
16
- * Both functions are pure server-side helpers. They do NOT consume a
17
- * routing turn (no session.round++, no dispatchToRole). Callers that
18
- * choose to expose them as Claude tools (via canCallTool / mcp) must
19
- * wire that separately — task-330a defines the contract; tool-binding
20
- * lands in 330d alongside the role-state persistence layer.
21
- *
22
- * Red lines (per PM dispatch):
23
- * - Do not mutate routing protocol shape (.routes / displayBody).
24
- * - Do not break task-319/328 (parser tests stay green).
25
- * - Old session replay must keep working — we only add new keys to
26
- * session.roleStates[role], never remove.
27
- */
28
-
29
- import { sendCrewMessage, sendStatusUpdate } from './ui-messages.js';
30
- import { updateKanban, appendChangelog, updateFeatureIndex, isValidTaskId } from './task-files.js';
31
-
32
- /** Valid standby reasons — extend with care; consumers may grep for these. */
33
- export const STANDBY_REASONS = Object.freeze([
34
- 'task_closed', 'awaiting_input', 'manual', 'idle', 'paused',
35
- ]);
36
-
37
- /**
38
- * Mark a task as complete on the kanban + broadcast a status card.
39
- *
40
- * Mirrors the side-effects that role-output.js performs when it detects a
41
- * completed TASKS block, so the two paths converge on the same kanban
42
- * state regardless of which one fires.
43
- *
44
- * @param {object} session
45
- * @param {{ taskId: string, summary?: string, fromRole?: string }} params
46
- * @returns {Promise<{ ok: boolean, taskId: string, reason?: string }>}
47
- */
48
- export async function taskClose(session, { taskId, summary, fromRole }) {
49
- if (!session) {
50
- return { ok: false, taskId: taskId || null, reason: 'no_session' };
51
- }
52
- if (!taskId || !isValidTaskId(taskId)) {
53
- return { ok: false, taskId: taskId || null, reason: 'invalid_task_id' };
54
- }
55
-
56
- // Mark in the in-memory completion set so future kanban rebuilds keep it.
57
- if (!session._completedTaskIds) session._completedTaskIds = new Set();
58
- const wasAlreadyCompleted = session._completedTaskIds.has(taskId);
59
- session._completedTaskIds.add(taskId);
60
-
61
- const feature = session.features?.get(taskId);
62
- const taskTitle = feature?.taskTitle || taskId;
63
- const cleanSummary = (summary && String(summary).trim()) || '已完成';
64
-
65
- // Persist to the kanban file (best-effort; warns on failure).
66
- try {
67
- await updateKanban(session, { taskId, completed: true, summary: cleanSummary });
68
- } catch (e) {
69
- console.warn(`[Crew] taskClose: updateKanban failed for ${taskId}:`, e.message);
70
- }
71
-
72
- // Append to features index + changelog only on first close (idempotent).
73
- if (!wasAlreadyCompleted) {
74
- updateFeatureIndex(session)
75
- .catch(e => console.warn('[Crew] taskClose: updateFeatureIndex failed:', e.message));
76
- appendChangelog(session, taskId, taskTitle)
77
- .catch(e => console.warn(`[Crew] taskClose: appendChangelog failed for ${taskId}:`, e.message));
78
- }
79
-
80
- // Broadcast status card so the UI reflects the close immediately.
81
- try {
82
- sendCrewMessage({
83
- type: 'crew_task_closed',
84
- sessionId: session.id,
85
- taskId,
86
- taskTitle,
87
- summary: cleanSummary,
88
- fromRole: fromRole || null,
89
- timestamp: Date.now(),
90
- });
91
- sendStatusUpdate(session);
92
- } catch (e) {
93
- console.warn(`[Crew] taskClose: broadcast failed for ${taskId}:`, e.message);
94
- }
95
-
96
- return { ok: true, taskId };
97
- }
98
-
99
- /**
100
- * Flip a role into 'standby' (in-memory) and broadcast.
101
- *
102
- * 330a defines the contract; 330d wires the .crew/context/role-states.json
103
- * persistence. We DO mutate `session.roleStates[role].standby` here so the
104
- * UI / status pipeline can reflect the change immediately, and 330d can
105
- * snapshot it on the next debounced save.
106
- *
107
- * @param {object} session
108
- * @param {{ role: string, reason?: string, fromRole?: string }} params
109
- * @returns {{ ok: boolean, role: string, reason?: string }}
110
- */
111
- export function roleStandby(session, { role, reason, fromRole }) {
112
- if (!session) {
113
- return { ok: false, role: role || null, reason: 'no_session' };
114
- }
115
- if (!role || typeof role !== 'string') {
116
- return { ok: false, role: role || null, reason: 'invalid_role' };
117
- }
118
- if (!session.roles || !session.roles.has(role)) {
119
- return { ok: false, role, reason: 'unknown_role' };
120
- }
121
-
122
- const normalizedReason = reason && STANDBY_REASONS.includes(reason)
123
- ? reason
124
- : 'manual';
125
-
126
- // Ensure the roleState bucket exists (cold-start safe).
127
- let roleState = session.roleStates?.get?.(role);
128
- if (!roleState) {
129
- roleState = {};
130
- session.roleStates?.set?.(role, roleState);
131
- }
132
- // New keys only — never delete legacy fields (replay safety).
133
- roleState.standby = {
134
- reason: normalizedReason,
135
- since: Date.now(),
136
- setBy: fromRole || null,
137
- };
138
-
139
- try {
140
- sendCrewMessage({
141
- type: 'crew_role_standby',
142
- sessionId: session.id,
143
- role,
144
- reason: normalizedReason,
145
- fromRole: fromRole || null,
146
- timestamp: Date.now(),
147
- });
148
- sendStatusUpdate(session);
149
- } catch (e) {
150
- console.warn(`[Crew] roleStandby: broadcast failed for ${role}:`, e.message);
151
- }
152
-
153
- return { ok: true, role, reason: normalizedReason };
154
- }
@@ -1,171 +0,0 @@
1
- /**
2
- * Crew Context Loader — detect and parse .crew/ directory structure.
3
- *
4
- * Reads .crew/CLAUDE.md, session.json, per-role CLAUDE.md files,
5
- * kanban, and feature files. Used by the frontend to detect whether
6
- * a project has a Crew setup and display role metadata.
7
- */
8
-
9
- import { join } from 'path';
10
- import { readFileSync, existsSync, readdirSync } from 'fs';
11
- import ctx from '../context.js';
12
-
13
- const MAX_CLAUDE_MD_LEN = 8192;
14
-
15
- /**
16
- * Read a file, returning null on any error.
17
- * @param {string} filePath
18
- * @returns {string|null}
19
- */
20
- function readFileOrNull(filePath) {
21
- try {
22
- if (!existsSync(filePath)) return null;
23
- return readFileSync(filePath, 'utf-8');
24
- } catch {
25
- return null;
26
- }
27
- }
28
-
29
- /**
30
- * Deduplicate Crew roles by roleType.
31
- * Crew may have dev-1, dev-2, dev-3 — collapse to a single "dev" entry.
32
- * Attaches per-role CLAUDE.md content.
33
- */
34
- function deduplicateRoles(sessionRoles, roleClaudes) {
35
- const byType = new Map();
36
- const merged = [];
37
-
38
- for (const r of sessionRoles) {
39
- const type = r.roleType || r.name;
40
- const claudeMd = roleClaudes[r.name] || '';
41
-
42
- if (byType.has(type)) continue;
43
- byType.set(type, true);
44
-
45
- const name = type;
46
- let displayName = r.displayName || name;
47
- displayName = displayName.replace(/-\d+$/, '');
48
-
49
- merged.push({
50
- name,
51
- displayName,
52
- icon: r.icon || '',
53
- description: r.description || '',
54
- claudeMd: claudeMd.substring(0, MAX_CLAUDE_MD_LEN),
55
- roleType: type,
56
- isDecisionMaker: !!r.isDecisionMaker,
57
- });
58
- }
59
-
60
- return merged;
61
- }
62
-
63
- /**
64
- * Load .crew context from a project directory.
65
- * Returns null if .crew/ doesn't exist.
66
- */
67
- export function loadCrewContext(projectDir) {
68
- const crewDir = join(projectDir, '.crew');
69
- if (!existsSync(crewDir)) return null;
70
-
71
- // 1. Shared CLAUDE.md
72
- const sharedClaudeMd = readFileOrNull(join(crewDir, 'CLAUDE.md')) || '';
73
-
74
- // 2. session.json → roles, teamType, language, features
75
- let sessionRoles = [];
76
- let teamType = 'dev';
77
- let language = 'zh-CN';
78
- let sessionFeatures = [];
79
- const sessionPath = join(crewDir, 'session.json');
80
- const sessionJson = readFileOrNull(sessionPath);
81
- if (sessionJson) {
82
- try {
83
- const session = JSON.parse(sessionJson);
84
- if (Array.isArray(session.roles)) sessionRoles = session.roles;
85
- if (session.teamType) teamType = session.teamType;
86
- if (session.language) language = session.language;
87
- if (Array.isArray(session.features)) sessionFeatures = session.features;
88
- } catch {
89
- // Invalid JSON — ignore
90
- }
91
- }
92
-
93
- // 3. Per-role CLAUDE.md from .crew/roles/*/CLAUDE.md
94
- const roleClaudes = {};
95
- const rolesDir = join(crewDir, 'roles');
96
- if (existsSync(rolesDir)) {
97
- try {
98
- const roleDirs = readdirSync(rolesDir, { withFileTypes: true })
99
- .filter(d => d.isDirectory())
100
- .map(d => d.name);
101
- for (const dirName of roleDirs) {
102
- const md = readFileOrNull(join(rolesDir, dirName, 'CLAUDE.md'));
103
- if (md) roleClaudes[dirName] = md;
104
- }
105
- } catch {
106
- // Permission error or similar — ignore
107
- }
108
- }
109
-
110
- // 4. Merge roles: deduplicate by roleType, attach claudeMd
111
- const roles = deduplicateRoles(sessionRoles, roleClaudes);
112
-
113
- // 5. Kanban
114
- const kanban = readFileOrNull(join(crewDir, 'context', 'kanban.md')) || '';
115
-
116
- // 6. Feature files from context/features/*.md
117
- const features = [];
118
- const featuresDir = join(crewDir, 'context', 'features');
119
- if (existsSync(featuresDir)) {
120
- try {
121
- const files = readdirSync(featuresDir)
122
- .filter(f => f.endsWith('.md') && f !== 'index.md')
123
- .sort();
124
- for (const f of files) {
125
- const content = readFileOrNull(join(featuresDir, f));
126
- if (content) {
127
- features.push({ name: f.replace('.md', ''), content });
128
- }
129
- }
130
- } catch {
131
- // ignore
132
- }
133
- }
134
-
135
- return { sharedClaudeMd, roles, kanban, features, teamType, language, sessionFeatures };
136
- }
137
-
138
- /**
139
- * Handle check_crew_context message — check if a project has .crew/ setup
140
- * and return role metadata for the frontend.
141
- */
142
- export function handleCheckCrewContext(msg) {
143
- const { projectDir, requestId } = msg;
144
- if (!projectDir) {
145
- ctx.sendToServer({ type: 'crew_context_result', requestId, found: false });
146
- return;
147
- }
148
- const crewContext = loadCrewContext(projectDir);
149
- if (!crewContext) {
150
- ctx.sendToServer({ type: 'crew_context_result', requestId, found: false });
151
- return;
152
- }
153
- // Return a safe subset for the frontend (no full claudeMd content, just metadata)
154
- ctx.sendToServer({
155
- type: 'crew_context_result',
156
- requestId,
157
- found: true,
158
- roles: crewContext.roles.map(r => ({
159
- name: r.name,
160
- displayName: r.displayName,
161
- icon: r.icon,
162
- description: r.description,
163
- roleType: r.roleType,
164
- isDecisionMaker: r.isDecisionMaker,
165
- hasClaudeMd: !!(r.claudeMd && r.claudeMd.length > 0),
166
- })),
167
- teamType: crewContext.teamType,
168
- language: crewContext.language,
169
- featureCount: crewContext.features.length,
170
- });
171
- }