@yeaft/webchat-agent 0.1.75 → 0.1.76
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude.js +9 -1
- package/conversation.js +25 -2
- package/package.json +1 -1
- package/roleplay.js +229 -4
package/claude.js
CHANGED
|
@@ -8,7 +8,9 @@ import {
|
|
|
8
8
|
detectRoleSignal,
|
|
9
9
|
processRolePlayRoutes,
|
|
10
10
|
buildRouteEventMessage,
|
|
11
|
-
getRolePlayRouteState
|
|
11
|
+
getRolePlayRouteState,
|
|
12
|
+
refreshCrewContext,
|
|
13
|
+
writeBackRouteContext
|
|
12
14
|
} from './roleplay.js';
|
|
13
15
|
|
|
14
16
|
/**
|
|
@@ -447,6 +449,12 @@ async function processClaudeOutput(conversationId, claudeQuery, state) {
|
|
|
447
449
|
ctx.sendToServer(routeEvent);
|
|
448
450
|
}
|
|
449
451
|
|
|
452
|
+
// ★ Write-back: persist route task info to .crew/context/features
|
|
453
|
+
const taskRoutes = routes.filter(r => r.taskId && r.summary);
|
|
454
|
+
if (taskRoutes.length > 0 && state.workDir) {
|
|
455
|
+
writeBackRouteContext(state.workDir, taskRoutes, rpSession.currentRole || 'unknown', rpSession);
|
|
456
|
+
}
|
|
457
|
+
|
|
450
458
|
// Send route state update
|
|
451
459
|
const routeState = getRolePlayRouteState(conversationId);
|
|
452
460
|
if (routeState) {
|
package/conversation.js
CHANGED
|
@@ -2,7 +2,7 @@ import ctx from './context.js';
|
|
|
2
2
|
import { loadSessionHistory } from './history.js';
|
|
3
3
|
import { startClaudeQuery } from './claude.js';
|
|
4
4
|
import { crewSessions, loadCrewIndex } from './crew.js';
|
|
5
|
-
import { rolePlaySessions, saveRolePlayIndex, removeRolePlaySession, loadRolePlayIndex, validateRolePlayConfig, initRolePlayRouteState, loadCrewContext } from './roleplay.js';
|
|
5
|
+
import { rolePlaySessions, saveRolePlayIndex, removeRolePlaySession, loadRolePlayIndex, validateRolePlayConfig, initRolePlayRouteState, loadCrewContext, refreshCrewContext, initCrewContextMtimes } from './roleplay.js';
|
|
6
6
|
|
|
7
7
|
// Restore persisted roleplay sessions on module load (agent startup)
|
|
8
8
|
loadRolePlayIndex();
|
|
@@ -256,10 +256,21 @@ export async function resumeConversation(msg) {
|
|
|
256
256
|
// Claude 进程会在用户发送第一条消息时启动 (见 handleUserInput)
|
|
257
257
|
// Restore rolePlayConfig from persisted rolePlaySessions if available
|
|
258
258
|
const rolePlayEntry = rolePlaySessions.get(conversationId);
|
|
259
|
-
|
|
259
|
+
let rolePlayConfig = rolePlayEntry
|
|
260
260
|
? { roles: rolePlayEntry.roles, teamType: rolePlayEntry.teamType, language: rolePlayEntry.language }
|
|
261
261
|
: null;
|
|
262
262
|
|
|
263
|
+
// ★ RolePlay resume: refresh .crew context to get latest kanban/features
|
|
264
|
+
if (rolePlayConfig && rolePlayEntry) {
|
|
265
|
+
const crewContext = loadCrewContext(effectiveWorkDir);
|
|
266
|
+
if (crewContext) {
|
|
267
|
+
rolePlayConfig.crewContext = crewContext;
|
|
268
|
+
// Initialize mtime snapshot (without re-loading) so subsequent refreshes can detect changes
|
|
269
|
+
initCrewContextMtimes(effectiveWorkDir, rolePlayEntry);
|
|
270
|
+
console.log(`[Resume] RolePlay: refreshed .crew context (${crewContext.features.length} features)`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
263
274
|
ctx.conversations.set(conversationId, {
|
|
264
275
|
query: null,
|
|
265
276
|
inputStream: null,
|
|
@@ -483,6 +494,18 @@ export async function handleUserInput(msg) {
|
|
|
483
494
|
const resumeSessionId = claudeSessionId || state?.claudeSessionId || null;
|
|
484
495
|
const effectiveWorkDir = workDir || state?.workDir || ctx.CONFIG.workDir;
|
|
485
496
|
|
|
497
|
+
// ★ RolePlay: refresh .crew context before starting a new query
|
|
498
|
+
// so the appendSystemPrompt has the latest kanban/features
|
|
499
|
+
if (state?.rolePlayConfig) {
|
|
500
|
+
const rpSession = rolePlaySessions.get(conversationId);
|
|
501
|
+
if (rpSession) {
|
|
502
|
+
const refreshed = refreshCrewContext(effectiveWorkDir, rpSession, state);
|
|
503
|
+
if (refreshed) {
|
|
504
|
+
console.log(`[SDK] RolePlay: .crew context refreshed before query start`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
486
509
|
console.log(`[SDK] Starting Claude for ${conversationId}, resume: ${resumeSessionId || 'none'}`);
|
|
487
510
|
state = await startClaudeQuery(conversationId, effectiveWorkDir, resumeSessionId);
|
|
488
511
|
}
|
package/package.json
CHANGED
package/roleplay.js
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
|
|
10
10
|
import { join } from 'path';
|
|
11
11
|
import { homedir } from 'os';
|
|
12
|
-
import { readFileSync, writeFileSync, existsSync, renameSync, readdirSync } from 'fs';
|
|
12
|
+
import { readFileSync, writeFileSync, existsSync, renameSync, readdirSync, statSync } from 'fs';
|
|
13
|
+
import { promises as fsp } from 'fs';
|
|
13
14
|
import { parseRoutes } from './crew/routing.js';
|
|
14
15
|
|
|
15
16
|
const ROLEPLAY_INDEX_PATH = join(homedir(), '.claude', 'roleplay-sessions.json');
|
|
@@ -26,8 +27,8 @@ export const rolePlaySessions = new Map();
|
|
|
26
27
|
export function saveRolePlayIndex() {
|
|
27
28
|
const data = [];
|
|
28
29
|
for (const [id, session] of rolePlaySessions) {
|
|
29
|
-
// Only persist core fields, skip runtime route state
|
|
30
|
-
const { _routeInitialized, currentRole, features, round, roleStates, waitingHuman, waitingHumanContext, ...core } = session;
|
|
30
|
+
// Only persist core fields, skip runtime route state and mtime snapshots
|
|
31
|
+
const { _routeInitialized, _crewContextMtimes, currentRole, features, round, roleStates, waitingHuman, waitingHumanContext, ...core } = session;
|
|
31
32
|
data.push({ id, ...core });
|
|
32
33
|
}
|
|
33
34
|
try {
|
|
@@ -218,7 +219,7 @@ function deduplicateRoles(sessionRoles, roleClaudes) {
|
|
|
218
219
|
const ALLOWED_TEAM_TYPES = ['dev', 'writing', 'trading', 'video', 'custom'];
|
|
219
220
|
const MAX_ROLE_NAME_LEN = 64;
|
|
220
221
|
const MAX_DISPLAY_NAME_LEN = 128;
|
|
221
|
-
const MAX_CLAUDE_MD_LEN =
|
|
222
|
+
const MAX_CLAUDE_MD_LEN = 8192;
|
|
222
223
|
const MAX_ROLES = 10;
|
|
223
224
|
|
|
224
225
|
/**
|
|
@@ -716,3 +717,227 @@ function buildCrewContextBlock(crewContext, isZh) {
|
|
|
716
717
|
|
|
717
718
|
return sections.join('\n\n');
|
|
718
719
|
}
|
|
720
|
+
|
|
721
|
+
// ---------------------------------------------------------------------------
|
|
722
|
+
// .crew context refresh (mtime-based change detection)
|
|
723
|
+
// ---------------------------------------------------------------------------
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Get mtime of a file, or 0 if it doesn't exist.
|
|
727
|
+
* @param {string} filePath
|
|
728
|
+
* @returns {number} mtime in ms
|
|
729
|
+
*/
|
|
730
|
+
function getMtimeMs(filePath) {
|
|
731
|
+
try {
|
|
732
|
+
return statSync(filePath).mtimeMs;
|
|
733
|
+
} catch {
|
|
734
|
+
return 0;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Collect mtimes for all .crew context files that matter for RolePlay.
|
|
740
|
+
* Returns a map of { relativePath → mtimeMs }.
|
|
741
|
+
*
|
|
742
|
+
* @param {string} projectDir
|
|
743
|
+
* @returns {Map<string, number>}
|
|
744
|
+
*/
|
|
745
|
+
function collectCrewContextMtimes(projectDir) {
|
|
746
|
+
const crewDir = join(projectDir, '.crew');
|
|
747
|
+
const mtimes = new Map();
|
|
748
|
+
|
|
749
|
+
// Shared CLAUDE.md
|
|
750
|
+
mtimes.set('CLAUDE.md', getMtimeMs(join(crewDir, 'CLAUDE.md')));
|
|
751
|
+
|
|
752
|
+
// context/kanban.md
|
|
753
|
+
mtimes.set('context/kanban.md', getMtimeMs(join(crewDir, 'context', 'kanban.md')));
|
|
754
|
+
|
|
755
|
+
// context/features/*.md
|
|
756
|
+
const featuresDir = join(crewDir, 'context', 'features');
|
|
757
|
+
if (existsSync(featuresDir)) {
|
|
758
|
+
try {
|
|
759
|
+
const files = readdirSync(featuresDir).filter(f => f.endsWith('.md') && f !== 'index.md');
|
|
760
|
+
for (const f of files) {
|
|
761
|
+
mtimes.set(`context/features/${f}`, getMtimeMs(join(featuresDir, f)));
|
|
762
|
+
}
|
|
763
|
+
} catch { /* ignore */ }
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// session.json (roles may change)
|
|
767
|
+
mtimes.set('session.json', getMtimeMs(join(crewDir, 'session.json')));
|
|
768
|
+
|
|
769
|
+
return mtimes;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Check if .crew context has changed since last snapshot.
|
|
774
|
+
*
|
|
775
|
+
* @param {Map<string, number>} oldMtimes - previous mtime snapshot
|
|
776
|
+
* @param {Map<string, number>} newMtimes - current mtime snapshot
|
|
777
|
+
* @returns {boolean} true if any file has been added, removed, or modified
|
|
778
|
+
*/
|
|
779
|
+
function hasCrewContextChanged(oldMtimes, newMtimes) {
|
|
780
|
+
if (!oldMtimes) return true; // first check → always refresh
|
|
781
|
+
|
|
782
|
+
// Defensive: if oldMtimes was deserialized from JSON (plain object, not Map),
|
|
783
|
+
// treat as stale and force refresh
|
|
784
|
+
if (!(oldMtimes instanceof Map)) return true;
|
|
785
|
+
|
|
786
|
+
// Check for new or modified files
|
|
787
|
+
for (const [path, mtime] of newMtimes) {
|
|
788
|
+
if (!oldMtimes.has(path) || oldMtimes.get(path) !== mtime) return true;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Check for deleted files
|
|
792
|
+
for (const path of oldMtimes.keys()) {
|
|
793
|
+
if (!newMtimes.has(path)) return true;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
return false;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Initialize the mtime snapshot for a RolePlay session without reloading context.
|
|
801
|
+
* Use this when the caller has already loaded crewContext (e.g. resume path)
|
|
802
|
+
* to avoid a redundant disk read.
|
|
803
|
+
*
|
|
804
|
+
* @param {string} projectDir - absolute path to project root
|
|
805
|
+
* @param {object} rpSession - rolePlaySessions entry
|
|
806
|
+
*/
|
|
807
|
+
export function initCrewContextMtimes(projectDir, rpSession) {
|
|
808
|
+
if (!projectDir || !existsSync(join(projectDir, '.crew'))) return;
|
|
809
|
+
rpSession._crewContextMtimes = collectCrewContextMtimes(projectDir);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Refresh .crew context for a RolePlay session if files have changed.
|
|
814
|
+
* Updates the session's crewContext and returns true if refreshed.
|
|
815
|
+
*
|
|
816
|
+
* Call this before building the system prompt (on resume, or before each turn).
|
|
817
|
+
*
|
|
818
|
+
* @param {string} projectDir - absolute path to project root
|
|
819
|
+
* @param {object} rpSession - rolePlaySessions entry
|
|
820
|
+
* @param {object} convState - ctx.conversations entry (has rolePlayConfig)
|
|
821
|
+
* @returns {boolean} true if context was refreshed
|
|
822
|
+
*/
|
|
823
|
+
export function refreshCrewContext(projectDir, rpSession, convState) {
|
|
824
|
+
if (!projectDir || !existsSync(join(projectDir, '.crew'))) return false;
|
|
825
|
+
|
|
826
|
+
const newMtimes = collectCrewContextMtimes(projectDir);
|
|
827
|
+
|
|
828
|
+
// Compare with stored snapshot
|
|
829
|
+
if (!hasCrewContextChanged(rpSession._crewContextMtimes, newMtimes)) {
|
|
830
|
+
return false; // no change
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Reload
|
|
834
|
+
const crewContext = loadCrewContext(projectDir);
|
|
835
|
+
if (!crewContext) return false;
|
|
836
|
+
|
|
837
|
+
// Update session and convState
|
|
838
|
+
rpSession._crewContextMtimes = newMtimes;
|
|
839
|
+
|
|
840
|
+
if (convState && convState.rolePlayConfig) {
|
|
841
|
+
convState.rolePlayConfig.crewContext = crewContext;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
console.log(`[RolePlay] Crew context refreshed from ${projectDir} (${crewContext.features.length} features, kanban: ${crewContext.kanban ? 'yes' : 'no'})`);
|
|
845
|
+
return true;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// ---------------------------------------------------------------------------
|
|
849
|
+
// .crew context write-back (RolePlay → .crew/context)
|
|
850
|
+
// ---------------------------------------------------------------------------
|
|
851
|
+
|
|
852
|
+
// Write lock for atomic write-back
|
|
853
|
+
let _writeBackLock = Promise.resolve();
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Atomic write: write to temp file then rename.
|
|
857
|
+
* @param {string} filePath
|
|
858
|
+
* @param {string} content
|
|
859
|
+
*/
|
|
860
|
+
async function atomicWrite(filePath, content) {
|
|
861
|
+
const tmpPath = filePath + '.tmp.' + Date.now();
|
|
862
|
+
try {
|
|
863
|
+
await fsp.writeFile(tmpPath, content);
|
|
864
|
+
await fsp.rename(tmpPath, filePath);
|
|
865
|
+
} catch (e) {
|
|
866
|
+
// Clean up temp file on failure
|
|
867
|
+
try { await fsp.unlink(tmpPath); } catch { /* ignore */ }
|
|
868
|
+
throw e;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Write back RolePlay route output to .crew/context files.
|
|
874
|
+
* Called after processRolePlayRoutes detects ROUTE blocks with task info.
|
|
875
|
+
*
|
|
876
|
+
* - Creates/updates context/features/{taskId}.md with route summary
|
|
877
|
+
* - Serialized via write lock to prevent concurrent corruption
|
|
878
|
+
*
|
|
879
|
+
* NOTE: The atomic write (tmp→rename) prevents partial writes within this
|
|
880
|
+
* process, and the serial lock prevents intra-process races. However, if a
|
|
881
|
+
* Crew session in a separate process writes the same file concurrently,
|
|
882
|
+
* a TOCTOU race is possible (read-then-write is not locked across processes).
|
|
883
|
+
* In practice this is acceptable: Crew and RolePlay rarely write the same
|
|
884
|
+
* task file simultaneously, and the worst case is a lost append (not corruption).
|
|
885
|
+
*
|
|
886
|
+
* @param {string} projectDir - absolute path to project root
|
|
887
|
+
* @param {Array<{to: string, summary: string, taskId?: string, taskTitle?: string}>} routes
|
|
888
|
+
* @param {string} fromRole - name of the role that produced the output
|
|
889
|
+
* @param {object} rpSession - rolePlaySessions entry
|
|
890
|
+
*/
|
|
891
|
+
export function writeBackRouteContext(projectDir, routes, fromRole, rpSession) {
|
|
892
|
+
if (!projectDir || !routes || routes.length === 0) return;
|
|
893
|
+
|
|
894
|
+
const crewDir = join(projectDir, '.crew');
|
|
895
|
+
if (!existsSync(crewDir)) return;
|
|
896
|
+
|
|
897
|
+
const doWriteBack = async () => {
|
|
898
|
+
const featuresDir = join(crewDir, 'context', 'features');
|
|
899
|
+
|
|
900
|
+
for (const route of routes) {
|
|
901
|
+
const { taskId, taskTitle, summary, to } = route;
|
|
902
|
+
if (!taskId || !summary) continue;
|
|
903
|
+
|
|
904
|
+
// ★ Sanitize taskId: only allow alphanumeric, hyphens, underscores
|
|
905
|
+
// Prevents path traversal (e.g. "../" in taskId from Claude output)
|
|
906
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(taskId)) {
|
|
907
|
+
console.warn(`[RolePlay] Write-back rejected: invalid taskId "${taskId}"`);
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
try {
|
|
912
|
+
await fsp.mkdir(featuresDir, { recursive: true });
|
|
913
|
+
const filePath = join(featuresDir, `${taskId}.md`);
|
|
914
|
+
|
|
915
|
+
let content;
|
|
916
|
+
try {
|
|
917
|
+
content = await fsp.readFile(filePath, 'utf-8');
|
|
918
|
+
} catch {
|
|
919
|
+
// File doesn't exist — create it
|
|
920
|
+
const isZh = rpSession.language === 'zh-CN';
|
|
921
|
+
content = `# ${isZh ? 'Feature' : 'Feature'}: ${taskTitle || taskId}\n- task-id: ${taskId}\n\n## ${isZh ? '工作记录' : 'Work Record'}\n`;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Append the route record
|
|
925
|
+
const fromRoleConfig = rpSession.roles?.find(r => r.name === fromRole);
|
|
926
|
+
const fromLabel = fromRoleConfig
|
|
927
|
+
? (fromRoleConfig.icon ? `${fromRoleConfig.icon} ${fromRoleConfig.displayName}` : fromRoleConfig.displayName)
|
|
928
|
+
: fromRole;
|
|
929
|
+
const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
|
|
930
|
+
const record = `\n### ${fromLabel} → ${to} - ${now}\n${summary}\n`;
|
|
931
|
+
|
|
932
|
+
await atomicWrite(filePath, content + record);
|
|
933
|
+
console.log(`[RolePlay] Write-back: task ${taskId} updated (${fromRole} → ${to})`);
|
|
934
|
+
} catch (e) {
|
|
935
|
+
console.warn(`[RolePlay] Write-back failed for ${taskId}:`, e.message);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
};
|
|
939
|
+
|
|
940
|
+
// Serialize write-backs
|
|
941
|
+
_writeBackLock = _writeBackLock.then(doWriteBack, doWriteBack);
|
|
942
|
+
return _writeBackLock;
|
|
943
|
+
}
|