mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-11 02:33:51 +08:00
feat: 增强会话管理功能,添加会话状态跟踪和进程披露,优化钩子管理界面
This commit is contained in:
@@ -56,6 +56,7 @@ Phase 2: Planning Document Validation
|
|||||||
└─ Validate .task/ contains IMPL-*.json files
|
└─ Validate .task/ contains IMPL-*.json files
|
||||||
|
|
||||||
Phase 3: TodoWrite Generation
|
Phase 3: TodoWrite Generation
|
||||||
|
├─ Update session status to "active" (Step 0)
|
||||||
├─ Parse TODO_LIST.md for task statuses
|
├─ Parse TODO_LIST.md for task statuses
|
||||||
├─ Generate TodoWrite for entire workflow
|
├─ Generate TodoWrite for entire workflow
|
||||||
└─ Prepare session context paths
|
└─ Prepare session context paths
|
||||||
@@ -80,6 +81,7 @@ Phase 5: Completion
|
|||||||
Resume Mode (--resume-session):
|
Resume Mode (--resume-session):
|
||||||
├─ Skip Phase 1 & Phase 2
|
├─ Skip Phase 1 & Phase 2
|
||||||
└─ Entry Point: Phase 3 (TodoWrite Generation)
|
└─ Entry Point: Phase 3 (TodoWrite Generation)
|
||||||
|
├─ Update session status to "active" (if not already)
|
||||||
└─ Continue: Phase 4 → Phase 5
|
└─ Continue: Phase 4 → Phase 5
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -179,6 +181,16 @@ bash(cat .workflow/active/${sessionId}/workflow-session.json)
|
|||||||
### Phase 3: TodoWrite Generation
|
### Phase 3: TodoWrite Generation
|
||||||
**Applies to**: Both normal and resume modes (resume mode entry point)
|
**Applies to**: Both normal and resume modes (resume mode entry point)
|
||||||
|
|
||||||
|
**Step 0: Update Session Status to Active**
|
||||||
|
Before generating TodoWrite, update session status from "planning" to "active":
|
||||||
|
```bash
|
||||||
|
# Update session status (idempotent - safe to run if already active)
|
||||||
|
jq '.status = "active" | .execution_started_at = (.execution_started_at // now | todate)' \
|
||||||
|
.workflow/active/${sessionId}/workflow-session.json > tmp.json && \
|
||||||
|
mv tmp.json .workflow/active/${sessionId}/workflow-session.json
|
||||||
|
```
|
||||||
|
This ensures the dashboard shows the session as "ACTIVE" during execution.
|
||||||
|
|
||||||
**Process**:
|
**Process**:
|
||||||
1. **Create TodoWrite List**: Generate task list from TODO_LIST.md (not from task JSONs)
|
1. **Create TodoWrite List**: Generate task list from TODO_LIST.md (not from task JSONs)
|
||||||
- Parse TODO_LIST.md to extract all tasks with current statuses
|
- Parse TODO_LIST.md to extract all tasks with current statuses
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
## MCP Tools Usage
|
## MCP Tools Usage
|
||||||
|
|
||||||
### smart_search - Code Search (REQUIRED)
|
### smart_search - Code Search (REQUIRED - HIGHEST PRIORITY)
|
||||||
|
|
||||||
**When**: Find code, understand codebase structure, locate implementations
|
**OVERRIDES**: All other search/discovery rules in other workflow files
|
||||||
|
|
||||||
|
**When**: ANY code discovery task, including:
|
||||||
|
- Find code, understand codebase structure, locate implementations
|
||||||
|
- Explore unknown locations
|
||||||
|
- Verify file existence before reading
|
||||||
|
- Pattern-based file discovery
|
||||||
|
|
||||||
|
**Priority Rule**:
|
||||||
|
1. **Always use smart_search FIRST** for any code/file discovery
|
||||||
|
2. Only use Built-in Grep for single-file exact line search (after location confirmed)
|
||||||
|
3. Only use Built-in Read for known, confirmed file paths
|
||||||
|
|
||||||
**Workflow** (search first, init if needed):
|
**Workflow** (search first, init if needed):
|
||||||
```javascript
|
```javascript
|
||||||
|
|||||||
@@ -40,27 +40,25 @@ write_file(path="/existing.ts", content="...", backup=true) // Create backup fi
|
|||||||
|
|
||||||
## Priority Logic
|
## Priority Logic
|
||||||
|
|
||||||
|
> **Note**: Search priority is defined in `context-tools.md` - smart_search has HIGHEST PRIORITY for all discovery tasks.
|
||||||
|
|
||||||
|
**Search & Discovery** (defer to context-tools.md):
|
||||||
|
1. **smart_search FIRST** for any code/file discovery
|
||||||
|
2. Built-in Grep only for single-file exact line search (location already confirmed)
|
||||||
|
3. Exa for external/public knowledge
|
||||||
|
|
||||||
**File Reading**:
|
**File Reading**:
|
||||||
1. Known single file → Built-in Read
|
1. Unknown location → **smart_search first**, then Read
|
||||||
2. Multiple files OR pattern matching → smart_search (MCP)
|
2. Known confirmed file → Built-in Read directly
|
||||||
3. Unknown location → smart_search then Read
|
3. Pattern matching → smart_search (action="find_files")
|
||||||
4. Large codebase + repeated access → smart_search (indexed)
|
|
||||||
|
|
||||||
**File Editing**:
|
**File Editing**:
|
||||||
1. Always try built-in Edit first
|
1. Always try built-in Edit first
|
||||||
2. Fails 1+ times → edit_file (MCP)
|
2. Fails 1+ times → edit_file (MCP)
|
||||||
3. Still fails → write_file (MCP)
|
3. Still fails → write_file (MCP)
|
||||||
|
|
||||||
**Search**:
|
|
||||||
1. External knowledge → Exa (MCP)
|
|
||||||
2. Exact pattern in small codebase → Built-in Grep
|
|
||||||
3. Semantic/unknown location → smart_search (MCP)
|
|
||||||
4. Large codebase + repeated searches → smart_search (indexed)
|
|
||||||
|
|
||||||
## Decision Triggers
|
## Decision Triggers
|
||||||
|
|
||||||
**Start with simplest tool** (Read, Edit, Grep)
|
**Search tasks** → Always start with smart_search (per context-tools.md)
|
||||||
**Escalate to MCP tools** when built-ins fail or inappropriate
|
**Known file edits** → Start with built-in Edit, escalate to MCP if fails
|
||||||
**Use semantic search** for exploratory tasks
|
**External knowledge** → Use Exa
|
||||||
**Use indexed search** for large, stable codebases
|
|
||||||
**Use Exa** for external/public knowledge
|
|
||||||
|
|||||||
@@ -180,6 +180,31 @@ function deleteHookFromSettings(projectPath, scope, event, hookIndex) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Session State Tracking (for progressive disclosure)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// Track sessions that have received startup context
|
||||||
|
// Key: sessionId, Value: timestamp of first context load
|
||||||
|
const sessionContextState = new Map<string, {
|
||||||
|
firstLoad: string;
|
||||||
|
loadCount: number;
|
||||||
|
lastPrompt?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Cleanup old sessions (older than 24 hours)
|
||||||
|
function cleanupOldSessions() {
|
||||||
|
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
||||||
|
for (const [sessionId, state] of sessionContextState.entries()) {
|
||||||
|
if (new Date(state.firstLoad).getTime() < cutoff) {
|
||||||
|
sessionContextState.delete(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run cleanup every hour
|
||||||
|
setInterval(cleanupOldSessions, 60 * 60 * 1000);
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// Route Handler
|
// Route Handler
|
||||||
// ========================================
|
// ========================================
|
||||||
@@ -260,6 +285,89 @@ export async function handleHooksRoutes(ctx: RouteContext): Promise<boolean> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// API: Unified Session Context endpoint (Progressive Disclosure)
|
||||||
|
// Automatically detects first prompt vs subsequent prompts
|
||||||
|
// - First prompt: returns cluster-based session overview
|
||||||
|
// - Subsequent prompts: returns intent-matched sessions based on prompt
|
||||||
|
if (pathname === '/api/hook/session-context' && req.method === 'POST') {
|
||||||
|
handlePostRequest(req, res, async (body) => {
|
||||||
|
const { sessionId, prompt } = body as { sessionId?: string; prompt?: string };
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
content: '',
|
||||||
|
error: 'sessionId is required'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const projectPath = url.searchParams.get('path') || initialPath;
|
||||||
|
const { SessionClusteringService } = await import('../session-clustering-service.js');
|
||||||
|
const clusteringService = new SessionClusteringService(projectPath);
|
||||||
|
|
||||||
|
// Check if this is the first prompt for this session
|
||||||
|
const existingState = sessionContextState.get(sessionId);
|
||||||
|
const isFirstPrompt = !existingState;
|
||||||
|
|
||||||
|
// Update session state
|
||||||
|
if (isFirstPrompt) {
|
||||||
|
sessionContextState.set(sessionId, {
|
||||||
|
firstLoad: new Date().toISOString(),
|
||||||
|
loadCount: 1,
|
||||||
|
lastPrompt: prompt
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
existingState.loadCount++;
|
||||||
|
existingState.lastPrompt = prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which type of context to return
|
||||||
|
let contextType: 'session-start' | 'context';
|
||||||
|
let content: string;
|
||||||
|
|
||||||
|
if (isFirstPrompt) {
|
||||||
|
// First prompt: return session overview with clusters
|
||||||
|
contextType = 'session-start';
|
||||||
|
content = await clusteringService.getProgressiveIndex({
|
||||||
|
type: 'session-start',
|
||||||
|
sessionId
|
||||||
|
});
|
||||||
|
} else if (prompt && prompt.trim().length > 0) {
|
||||||
|
// Subsequent prompts with content: return intent-matched sessions
|
||||||
|
contextType = 'context';
|
||||||
|
content = await clusteringService.getProgressiveIndex({
|
||||||
|
type: 'context',
|
||||||
|
sessionId,
|
||||||
|
prompt
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Subsequent prompts without content: return minimal context
|
||||||
|
contextType = 'context';
|
||||||
|
content = ''; // No context needed for empty prompts
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
type: contextType,
|
||||||
|
isFirstPrompt,
|
||||||
|
loadCount: sessionContextState.get(sessionId)?.loadCount || 1,
|
||||||
|
content,
|
||||||
|
sessionId
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Hooks] Failed to generate session context:', error);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
content: '',
|
||||||
|
sessionId,
|
||||||
|
error: (error as Error).message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// API: Get hooks configuration
|
// API: Get hooks configuration
|
||||||
if (pathname === '/api/hooks' && req.method === 'GET') {
|
if (pathname === '/api/hooks' && req.method === 'GET') {
|
||||||
const projectPathParam = url.searchParams.get('path');
|
const projectPathParam = url.searchParams.get('path');
|
||||||
|
|||||||
@@ -833,64 +833,110 @@ export class SessionClusteringService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get recent sessions index (for session-start)
|
* Get recent sessions index (for session-start)
|
||||||
|
* Shows sessions grouped by clusters with progressive disclosure
|
||||||
*/
|
*/
|
||||||
private async getRecentSessionsIndex(): Promise<string> {
|
private async getRecentSessionsIndex(): Promise<string> {
|
||||||
const sessions = await this.collectSessions({ scope: 'recent' });
|
// 1. Get all active clusters
|
||||||
|
const allClusters = this.coreMemoryStore.listClusters('active');
|
||||||
|
|
||||||
// Sort by created_at descending (most recent first)
|
// Sort clusters by most recent activity (based on member last_accessed)
|
||||||
const sortedSessions = sessions
|
const clustersWithActivity = allClusters.map(cluster => {
|
||||||
.filter(s => s.created_at)
|
const members = this.coreMemoryStore.getClusterMembers(cluster.id);
|
||||||
.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''))
|
const memberMetadata = members
|
||||||
.slice(0, 5); // Top 5 recent sessions
|
.map(m => this.coreMemoryStore.getSessionMetadata(m.session_id))
|
||||||
|
.filter((m): m is SessionMetadataCache => m !== null);
|
||||||
|
|
||||||
if (sortedSessions.length === 0) {
|
const lastActivity = memberMetadata.reduce((latest, m) => {
|
||||||
return `<ccw-session-context>
|
const accessed = m.last_accessed || m.created_at || '';
|
||||||
## 📋 Recent Sessions
|
return accessed > latest ? accessed : latest;
|
||||||
|
}, '');
|
||||||
|
|
||||||
No recent sessions found. Start a new workflow to begin tracking.
|
return { cluster, members, memberMetadata, lastActivity };
|
||||||
|
}).sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
|
||||||
|
|
||||||
**MCP Tools**:
|
// 2. Get unclustered recent sessions
|
||||||
\`\`\`
|
const allSessions = await this.collectSessions({ scope: 'recent' });
|
||||||
# Search sessions
|
const clusteredSessionIds = new Set<string>();
|
||||||
Use tool: mcp__ccw-tools__core_memory
|
clustersWithActivity.forEach(c => {
|
||||||
Parameters: { "action": "search", "query": "<keyword>" }
|
c.members.forEach(m => clusteredSessionIds.add(m.session_id));
|
||||||
|
|
||||||
# Create new session
|
|
||||||
Parameters: { "action": "save", "content": "<context>" }
|
|
||||||
\`\`\`
|
|
||||||
</ccw-session-context>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate table
|
|
||||||
let table = `| # | Session | Type | Title | Date |\n`;
|
|
||||||
table += `|---|---------|------|-------|------|\n`;
|
|
||||||
|
|
||||||
sortedSessions.forEach((s, idx) => {
|
|
||||||
const type = s.session_type === 'core_memory' ? 'Core' :
|
|
||||||
s.session_type === 'workflow' ? 'Workflow' : 'CLI';
|
|
||||||
const title = (s.title || '').substring(0, 40);
|
|
||||||
const date = s.created_at ? new Date(s.created_at).toLocaleDateString() : '';
|
|
||||||
table += `| ${idx + 1} | ${s.session_id} | ${type} | ${title} | ${date} |\n`;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return `<ccw-session-context>
|
const unclusteredSessions = allSessions
|
||||||
## 📋 Recent Sessions (Last 30 days)
|
.filter(s => !clusteredSessionIds.has(s.session_id))
|
||||||
|
.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''))
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
${table}
|
// 3. Build output
|
||||||
|
let output = `<ccw-session-context>\n## 📋 Session Context (Progressive Disclosure)\n\n`;
|
||||||
|
|
||||||
**Resume via MCP**:
|
// Show top 2 active clusters
|
||||||
\`\`\`
|
const topClusters = clustersWithActivity.slice(0, 2);
|
||||||
Use tool: mcp__ccw-tools__core_memory
|
if (topClusters.length > 0) {
|
||||||
Parameters: { "action": "load", "id": "${sortedSessions[0].session_id}" }
|
output += `### 🔗 Active Clusters\n\n`;
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
---
|
for (const { cluster, memberMetadata } of topClusters) {
|
||||||
**Tip**: Sessions are sorted by most recent. Use \`search\` action to find specific topics.
|
output += `**${cluster.name}** (${memberMetadata.length} sessions)\n`;
|
||||||
</ccw-session-context>`;
|
if (cluster.intent) {
|
||||||
|
output += `> Intent: ${cluster.intent}\n`;
|
||||||
|
}
|
||||||
|
output += `\n| Session | Type | Title |\n|---------|------|-------|\n`;
|
||||||
|
|
||||||
|
// Show top 3 members per cluster
|
||||||
|
const displayMembers = memberMetadata.slice(0, 3);
|
||||||
|
for (const m of displayMembers) {
|
||||||
|
const type = m.session_type === 'core_memory' ? 'Core' :
|
||||||
|
m.session_type === 'workflow' ? 'Workflow' : 'CLI';
|
||||||
|
const title = (m.title || '').substring(0, 35);
|
||||||
|
output += `| ${m.session_id} | ${type} | ${title} |\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (memberMetadata.length > 3) {
|
||||||
|
output += `| ... | ... | +${memberMetadata.length - 3} more |\n`;
|
||||||
|
}
|
||||||
|
output += `\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show unclustered recent sessions
|
||||||
|
if (unclusteredSessions.length > 0) {
|
||||||
|
output += `### 📝 Recent Sessions (Unclustered)\n\n`;
|
||||||
|
output += `| Session | Type | Title | Date |\n`;
|
||||||
|
output += `|---------|------|-------|------|\n`;
|
||||||
|
|
||||||
|
for (const s of unclusteredSessions) {
|
||||||
|
const type = s.session_type === 'core_memory' ? 'Core' :
|
||||||
|
s.session_type === 'workflow' ? 'Workflow' : 'CLI';
|
||||||
|
const title = (s.title || '').substring(0, 30);
|
||||||
|
const date = s.created_at ? new Date(s.created_at).toLocaleDateString() : '';
|
||||||
|
output += `| ${s.session_id} | ${type} | ${title} | ${date} |\n`;
|
||||||
|
}
|
||||||
|
output += `\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If nothing found
|
||||||
|
if (topClusters.length === 0 && unclusteredSessions.length === 0) {
|
||||||
|
output += `No recent sessions found. Start a new workflow to begin tracking.\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add MCP tools reference
|
||||||
|
const topSession = topClusters[0]?.memberMetadata[0] || unclusteredSessions[0];
|
||||||
|
const topClusterId = topClusters[0]?.cluster.id;
|
||||||
|
|
||||||
|
output += `**MCP Tools**:\n\`\`\`\n`;
|
||||||
|
if (topSession) {
|
||||||
|
output += `# Resume session\nmcp__ccw-tools__core_memory({ "operation": "export", "id": "${topSession.session_id}" })\n\n`;
|
||||||
|
}
|
||||||
|
if (topClusterId) {
|
||||||
|
output += `# Load cluster context\nmcp__ccw-tools__core_memory({ "operation": "search", "query": "cluster:${topClusterId}" })\n`;
|
||||||
|
}
|
||||||
|
output += `\`\`\`\n</ccw-session-context>`;
|
||||||
|
|
||||||
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get intent-matched sessions index (for context with prompt)
|
* Get intent-matched sessions index (for context with prompt)
|
||||||
|
* Shows sessions grouped by clusters and ranked by relevance
|
||||||
*/
|
*/
|
||||||
private async getIntentMatchedIndex(prompt: string, sessionId?: string): Promise<string> {
|
private async getIntentMatchedIndex(prompt: string, sessionId?: string): Promise<string> {
|
||||||
const sessions = await this.collectSessions({ scope: 'all' });
|
const sessions = await this.collectSessions({ scope: 'all' });
|
||||||
@@ -917,14 +963,27 @@ No sessions available for intent matching.
|
|||||||
access_count: 0
|
access_count: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Build session-to-cluster mapping
|
||||||
|
const sessionClusterMap = new Map<string, SessionCluster[]>();
|
||||||
|
const allClusters = this.coreMemoryStore.listClusters('active');
|
||||||
|
for (const cluster of allClusters) {
|
||||||
|
const members = this.coreMemoryStore.getClusterMembers(cluster.id);
|
||||||
|
for (const member of members) {
|
||||||
|
const existing = sessionClusterMap.get(member.session_id) || [];
|
||||||
|
existing.push(cluster);
|
||||||
|
sessionClusterMap.set(member.session_id, existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate relevance scores for all sessions
|
// Calculate relevance scores for all sessions
|
||||||
const scoredSessions = sessions
|
const scoredSessions = sessions
|
||||||
.filter(s => s.session_id !== sessionId) // Exclude current session
|
.filter(s => s.session_id !== sessionId) // Exclude current session
|
||||||
.map(s => ({
|
.map(s => ({
|
||||||
session: s,
|
session: s,
|
||||||
score: this.calculateRelevance(promptSession, s)
|
score: this.calculateRelevance(promptSession, s),
|
||||||
|
clusters: sessionClusterMap.get(s.session_id) || []
|
||||||
}))
|
}))
|
||||||
.filter(item => item.score >= 0.3) // Minimum relevance threshold
|
.filter(item => item.score >= 0.15) // Minimum relevance threshold (lowered for file-path-based keywords)
|
||||||
.sort((a, b) => b.score - a.score)
|
.sort((a, b) => b.score - a.score)
|
||||||
.slice(0, 8); // Top 8 relevant sessions
|
.slice(0, 8); // Top 8 relevant sessions
|
||||||
|
|
||||||
@@ -938,70 +997,82 @@ No sessions match current intent. Consider:
|
|||||||
|
|
||||||
**MCP Tools**:
|
**MCP Tools**:
|
||||||
\`\`\`
|
\`\`\`
|
||||||
Use tool: mcp__ccw-tools__core_memory
|
mcp__ccw-tools__core_memory({ "operation": "search", "query": "<keyword>" })
|
||||||
Parameters: { "action": "search", "query": "<keyword>" }
|
|
||||||
\`\`\`
|
\`\`\`
|
||||||
</ccw-session-context>`;
|
</ccw-session-context>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group by relevance tier
|
// Group sessions by cluster
|
||||||
const highRelevance = scoredSessions.filter(s => s.score >= 0.6);
|
const clusterGroups = new Map<string, { cluster: SessionCluster; sessions: typeof scoredSessions }>();
|
||||||
const mediumRelevance = scoredSessions.filter(s => s.score >= 0.4 && s.score < 0.6);
|
const unclusteredSessions: typeof scoredSessions = [];
|
||||||
const lowRelevance = scoredSessions.filter(s => s.score < 0.4);
|
|
||||||
|
for (const item of scoredSessions) {
|
||||||
|
if (item.clusters.length > 0) {
|
||||||
|
// Add to the highest-priority cluster
|
||||||
|
const primaryCluster = item.clusters[0];
|
||||||
|
const existing = clusterGroups.get(primaryCluster.id) || { cluster: primaryCluster, sessions: [] };
|
||||||
|
existing.sessions.push(item);
|
||||||
|
clusterGroups.set(primaryCluster.id, existing);
|
||||||
|
} else {
|
||||||
|
unclusteredSessions.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort cluster groups by best session score
|
||||||
|
const sortedGroups = Array.from(clusterGroups.values())
|
||||||
|
.sort((a, b) => Math.max(...b.sessions.map(s => s.score)) - Math.max(...a.sessions.map(s => s.score)));
|
||||||
|
|
||||||
// Generate output
|
// Generate output
|
||||||
let output = `<ccw-session-context>
|
let output = `<ccw-session-context>\n## 📋 Intent-Matched Sessions\n\n`;
|
||||||
## 📋 Intent-Matched Sessions
|
output += `**Detected Intent**: ${(promptSession.keywords || []).slice(0, 5).join(', ') || 'General'}\n\n`;
|
||||||
|
|
||||||
**Detected Intent**: ${(promptSession.keywords || []).slice(0, 5).join(', ') || 'General'}
|
// Show clustered sessions
|
||||||
|
if (sortedGroups.length > 0) {
|
||||||
|
output += `### 🔗 Matched Clusters\n\n`;
|
||||||
|
|
||||||
`;
|
for (const { cluster, sessions: clusterSessions } of sortedGroups.slice(0, 2)) {
|
||||||
|
const avgScore = Math.round(clusterSessions.reduce((sum, s) => sum + s.score, 0) / clusterSessions.length * 100);
|
||||||
|
output += `**${cluster.name}** (${avgScore}% avg match)\n`;
|
||||||
|
if (cluster.intent) {
|
||||||
|
output += `> ${cluster.intent}\n`;
|
||||||
|
}
|
||||||
|
output += `\n| Session | Match | Title |\n|---------|-------|-------|\n`;
|
||||||
|
|
||||||
if (highRelevance.length > 0) {
|
for (const item of clusterSessions.slice(0, 3)) {
|
||||||
output += `### 🔥 Highly Relevant (${highRelevance.length})\n`;
|
const matchPct = Math.round(item.score * 100);
|
||||||
output += `| Session | Type | Match | Summary |\n`;
|
const title = (item.session.title || '').substring(0, 35);
|
||||||
output += `|---------|------|-------|--------|\n`;
|
output += `| ${item.session.session_id} | ${matchPct}% | ${title} |\n`;
|
||||||
for (const item of highRelevance) {
|
}
|
||||||
|
output += `\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show unclustered sessions
|
||||||
|
if (unclusteredSessions.length > 0) {
|
||||||
|
output += `### 📝 Individual Matches\n\n`;
|
||||||
|
output += `| Session | Type | Match | Title |\n`;
|
||||||
|
output += `|---------|------|-------|-------|\n`;
|
||||||
|
|
||||||
|
for (const item of unclusteredSessions.slice(0, 4)) {
|
||||||
const type = item.session.session_type === 'core_memory' ? 'Core' :
|
const type = item.session.session_type === 'core_memory' ? 'Core' :
|
||||||
item.session.session_type === 'workflow' ? 'Workflow' : 'CLI';
|
item.session.session_type === 'workflow' ? 'Workflow' : 'CLI';
|
||||||
const matchPct = Math.round(item.score * 100);
|
const matchPct = Math.round(item.score * 100);
|
||||||
const summary = (item.session.title || item.session.summary || '').substring(0, 35);
|
const title = (item.session.title || '').substring(0, 30);
|
||||||
output += `| ${item.session.session_id} | ${type} | ${matchPct}% | ${summary} |\n`;
|
output += `| ${item.session.session_id} | ${type} | ${matchPct}% | ${title} |\n`;
|
||||||
}
|
}
|
||||||
output += `\n`;
|
output += `\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mediumRelevance.length > 0) {
|
// Add MCP tools reference
|
||||||
output += `### 📌 Related (${mediumRelevance.length})\n`;
|
const topSession = scoredSessions[0];
|
||||||
output += `| Session | Type | Match | Summary |\n`;
|
const topCluster = sortedGroups[0]?.cluster;
|
||||||
output += `|---------|------|-------|--------|\n`;
|
|
||||||
for (const item of mediumRelevance) {
|
output += `**MCP Tools**:\n\`\`\`\n`;
|
||||||
const type = item.session.session_type === 'core_memory' ? 'Core' :
|
output += `# Resume top match\nmcp__ccw-tools__core_memory({ "operation": "export", "id": "${topSession.session.session_id}" })\n`;
|
||||||
item.session.session_type === 'workflow' ? 'Workflow' : 'CLI';
|
if (topCluster) {
|
||||||
const matchPct = Math.round(item.score * 100);
|
output += `\n# Load cluster context\nmcp__ccw-tools__core_memory({ "operation": "search", "query": "cluster:${topCluster.id}" })\n`;
|
||||||
const summary = (item.session.title || item.session.summary || '').substring(0, 35);
|
|
||||||
output += `| ${item.session.session_id} | ${type} | ${matchPct}% | ${summary} |\n`;
|
|
||||||
}
|
|
||||||
output += `\n`;
|
|
||||||
}
|
}
|
||||||
|
output += `\`\`\`\n</ccw-session-context>`;
|
||||||
if (lowRelevance.length > 0) {
|
|
||||||
output += `### 💡 May Be Useful (${lowRelevance.length})\n`;
|
|
||||||
const sessionList = lowRelevance.map(s => s.session.session_id).join(', ');
|
|
||||||
output += `${sessionList}\n\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add resume command for top match
|
|
||||||
const topMatch = scoredSessions[0];
|
|
||||||
output += `**Resume Top Match**:
|
|
||||||
\`\`\`
|
|
||||||
Use tool: mcp__ccw-tools__core_memory
|
|
||||||
Parameters: { "action": "load", "id": "${topMatch.session.session_id}" }
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
---
|
|
||||||
**Tip**: Sessions ranked by semantic similarity to your prompt.
|
|
||||||
</ccw-session-context>`;
|
|
||||||
|
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,23 +138,14 @@ const HOOK_TEMPLATES = {
|
|||||||
category: 'memory',
|
category: 'memory',
|
||||||
timeout: 5000
|
timeout: 5000
|
||||||
},
|
},
|
||||||
// Session Context - Progressive Disclosure (session start - recent sessions)
|
// Session Context - Fires once per session at startup
|
||||||
|
// Uses state file to detect first prompt, only fires once
|
||||||
'session-context': {
|
'session-context': {
|
||||||
event: 'UserPromptSubmit',
|
event: 'UserPromptSubmit',
|
||||||
matcher: '',
|
matcher: '',
|
||||||
command: 'bash',
|
command: 'bash',
|
||||||
args: ['-c', 'curl -s -X POST -H "Content-Type: application/json" -d "{\\"type\\":\\"session-start\\",\\"sessionId\\":\\"$CLAUDE_SESSION_ID\\"}" http://localhost:3456/api/hook 2>/dev/null | jq -r ".content // empty"'],
|
args: ['-c', 'STATE_FILE="/tmp/.ccw-session-$CLAUDE_SESSION_ID"; [ -f "$STATE_FILE" ] && exit 0; touch "$STATE_FILE"; curl -s -X POST -H "Content-Type: application/json" -d "{\\"sessionId\\":\\"$CLAUDE_SESSION_ID\\"}" http://localhost:3456/api/hook/session-context 2>/dev/null | jq -r ".content // empty"'],
|
||||||
description: 'Load recent sessions at session start (time-sorted)',
|
description: 'Load session context once at startup (cluster overview)',
|
||||||
category: 'context',
|
|
||||||
timeout: 5000
|
|
||||||
},
|
|
||||||
// Session Context - Continuous Disclosure (intent matching on every prompt)
|
|
||||||
'session-context-continuous': {
|
|
||||||
event: 'UserPromptSubmit',
|
|
||||||
matcher: '',
|
|
||||||
command: 'bash',
|
|
||||||
args: ['-c', 'PROMPT=$(cat | jq -r ".prompt // empty"); curl -s -X POST -H "Content-Type: application/json" -d "{\\"type\\":\\"context\\",\\"sessionId\\":\\"$CLAUDE_SESSION_ID\\",\\"prompt\\":\\"$PROMPT\\"}" http://localhost:3456/api/hook 2>/dev/null | jq -r ".content // empty"'],
|
|
||||||
description: 'Load intent-matched sessions on every prompt (similarity-based)',
|
|
||||||
category: 'context',
|
category: 'context',
|
||||||
timeout: 5000
|
timeout: 5000
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ const i18n = {
|
|||||||
// Search
|
// Search
|
||||||
'search.placeholder': 'Search...',
|
'search.placeholder': 'Search...',
|
||||||
|
|
||||||
// Session cards
|
// Session cards - 3 states: planning, active, completed (archived location)
|
||||||
'session.status.active': 'ACTIVE',
|
'session.status.active': 'ACTIVE',
|
||||||
'session.status.archived': 'ARCHIVED',
|
'session.status.archived': 'ARCHIVED',
|
||||||
'session.status.planning': 'PLANNING',
|
'session.status.planning': 'PLANNING',
|
||||||
@@ -714,10 +714,8 @@ const i18n = {
|
|||||||
'hook.template.gitAddDesc': 'Auto stage written files',
|
'hook.template.gitAddDesc': 'Auto stage written files',
|
||||||
|
|
||||||
// Hook Quick Install Templates
|
// Hook Quick Install Templates
|
||||||
'hook.tpl.sessionContext': 'Session Context (Start)',
|
'hook.tpl.sessionContext': 'Session Context',
|
||||||
'hook.tpl.sessionContextDesc': 'Load recent sessions at session start (time-sorted)',
|
'hook.tpl.sessionContextDesc': 'Load cluster overview once at session start',
|
||||||
'hook.tpl.sessionContextContinuous': 'Session Context (Continuous)',
|
|
||||||
'hook.tpl.sessionContextContinuousDesc': 'Load intent-matched sessions on every prompt (similarity-based)',
|
|
||||||
'hook.tpl.codexlensSync': 'CodexLens Auto-Sync',
|
'hook.tpl.codexlensSync': 'CodexLens Auto-Sync',
|
||||||
'hook.tpl.codexlensSyncDesc': 'Auto-update code index when files are written or edited',
|
'hook.tpl.codexlensSyncDesc': 'Auto-update code index when files are written or edited',
|
||||||
'hook.tpl.ccwDashboardNotify': 'CCW Dashboard Notify',
|
'hook.tpl.ccwDashboardNotify': 'CCW Dashboard Notify',
|
||||||
@@ -2021,10 +2019,8 @@ const i18n = {
|
|||||||
'hook.template.gitAddDesc': '自动暂存写入的文件',
|
'hook.template.gitAddDesc': '自动暂存写入的文件',
|
||||||
|
|
||||||
// Hook Quick Install Templates
|
// Hook Quick Install Templates
|
||||||
'hook.tpl.sessionContext': 'Session 上下文(启动)',
|
'hook.tpl.sessionContext': 'Session 上下文',
|
||||||
'hook.tpl.sessionContextDesc': '会话启动时加载最近会话(按时间排序)',
|
'hook.tpl.sessionContextDesc': '会话启动时加载集群概览(仅触发一次)',
|
||||||
'hook.tpl.sessionContextContinuous': 'Session 上下文(持续)',
|
|
||||||
'hook.tpl.sessionContextContinuousDesc': '每次提示词时加载意图匹配会话(相似度排序)',
|
|
||||||
'hook.tpl.codexlensSync': 'CodexLens 自动同步',
|
'hook.tpl.codexlensSync': 'CodexLens 自动同步',
|
||||||
'hook.tpl.codexlensSyncDesc': '文件写入或编辑时自动更新代码索引',
|
'hook.tpl.codexlensSyncDesc': '文件写入或编辑时自动更新代码索引',
|
||||||
'hook.tpl.ccwDashboardNotify': 'CCW 控制面板通知',
|
'hook.tpl.ccwDashboardNotify': 'CCW 控制面板通知',
|
||||||
|
|||||||
@@ -102,24 +102,28 @@ function renderSessionCard(session) {
|
|||||||
const isActive = session._isActive !== false;
|
const isActive = session._isActive !== false;
|
||||||
const date = session.created_at;
|
const date = session.created_at;
|
||||||
|
|
||||||
// Detect planning status from session.status field
|
// Get session status from metadata (default to 'planning' for new sessions)
|
||||||
const isPlanning = session.status === 'planning';
|
// 3 states: planning → active → completed (archived)
|
||||||
|
const sessionStatus = session.status || 'planning';
|
||||||
|
const isPlanning = sessionStatus === 'planning';
|
||||||
|
|
||||||
// Get session type badge
|
// Get session type badge
|
||||||
const sessionType = session.type || 'workflow';
|
const sessionType = session.type || 'workflow';
|
||||||
const typeBadge = sessionType !== 'workflow' ? `<span class="session-type-badge ${sessionType}">${sessionType}</span>` : '';
|
const typeBadge = sessionType !== 'workflow' ? `<span class="session-type-badge ${sessionType}">${sessionType}</span>` : '';
|
||||||
|
|
||||||
// Determine status badge class and text
|
// Determine status badge class and text
|
||||||
// Priority: archived > planning > active
|
// Priority: archived location > planning status > active status
|
||||||
let statusClass, statusText;
|
let statusClass, statusText;
|
||||||
if (!isActive) {
|
if (!isActive) {
|
||||||
// Archived sessions always show as ARCHIVED regardless of status field
|
// Archived sessions (completed) always show as ARCHIVED
|
||||||
statusClass = 'archived';
|
statusClass = 'archived';
|
||||||
statusText = t('session.status.archived');
|
statusText = t('session.status.archived');
|
||||||
} else if (isPlanning) {
|
} else if (isPlanning) {
|
||||||
|
// Planning state - session created but not yet executed
|
||||||
statusClass = 'planning';
|
statusClass = 'planning';
|
||||||
statusText = t('session.status.planning');
|
statusText = t('session.status.planning');
|
||||||
} else {
|
} else {
|
||||||
|
// Active state - session is being executed
|
||||||
statusClass = 'active';
|
statusClass = 'active';
|
||||||
statusText = t('session.status.active');
|
statusText = t('session.status.active');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,7 +101,6 @@ async function renderHookManager() {
|
|||||||
|
|
||||||
<div class="hook-templates-grid grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="hook-templates-grid grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
${renderQuickInstallCard('session-context', t('hook.tpl.sessionContext'), t('hook.tpl.sessionContextDesc'), 'UserPromptSubmit', '')}
|
${renderQuickInstallCard('session-context', t('hook.tpl.sessionContext'), t('hook.tpl.sessionContextDesc'), 'UserPromptSubmit', '')}
|
||||||
${renderQuickInstallCard('session-context-continuous', t('hook.tpl.sessionContextContinuous'), t('hook.tpl.sessionContextContinuousDesc'), 'UserPromptSubmit', '')}
|
|
||||||
${renderQuickInstallCard('codexlens-update', t('hook.tpl.codexlensSync'), t('hook.tpl.codexlensSyncDesc'), 'PostToolUse', 'Write|Edit')}
|
${renderQuickInstallCard('codexlens-update', t('hook.tpl.codexlensSync'), t('hook.tpl.codexlensSyncDesc'), 'PostToolUse', 'Write|Edit')}
|
||||||
${renderQuickInstallCard('ccw-notify', t('hook.tpl.ccwDashboardNotify'), t('hook.tpl.ccwDashboardNotifyDesc'), 'PostToolUse', 'Write')}
|
${renderQuickInstallCard('ccw-notify', t('hook.tpl.ccwDashboardNotify'), t('hook.tpl.ccwDashboardNotifyDesc'), 'PostToolUse', 'Write')}
|
||||||
${renderQuickInstallCard('log-tool', t('hook.tpl.toolLogger'), t('hook.tpl.toolLoggerDesc'), 'PostToolUse', 'All')}
|
${renderQuickInstallCard('log-tool', t('hook.tpl.toolLogger'), t('hook.tpl.toolLoggerDesc'), 'PostToolUse', 'All')}
|
||||||
@@ -506,11 +505,37 @@ async function uninstallHookTemplate(templateId) {
|
|||||||
const template = HOOK_TEMPLATES[templateId];
|
const template = HOOK_TEMPLATES[templateId];
|
||||||
if (!template) return;
|
if (!template) return;
|
||||||
|
|
||||||
|
// Extract unique identifier from template args for matching
|
||||||
|
// Template args format: ['-c', 'actual command...']
|
||||||
|
const templateArgs = template.args || [];
|
||||||
|
const templateFullCmd = templateArgs.length > 0 ? templateArgs.join(' ') : '';
|
||||||
|
|
||||||
|
// Define unique patterns for each template type
|
||||||
|
const uniquePatterns = {
|
||||||
|
'session-context': 'api/hook/session-context',
|
||||||
|
'codexlens-update': 'codexlens update',
|
||||||
|
'ccw-notify': 'api/hook',
|
||||||
|
'log-tool': 'tool-usage.log',
|
||||||
|
'lint-check': 'eslint',
|
||||||
|
'git-add': 'git add',
|
||||||
|
'memory-file-read': 'memory track',
|
||||||
|
'memory-file-write': 'memory track',
|
||||||
|
'memory-prompt-track': 'memory track'
|
||||||
|
};
|
||||||
|
|
||||||
|
const uniquePattern = uniquePatterns[templateId] || template.command;
|
||||||
|
|
||||||
|
// Helper to check if a hook matches the template
|
||||||
|
const matchesTemplate = (h) => {
|
||||||
|
const hookCmd = h.hooks?.[0]?.command || h.command || '';
|
||||||
|
return hookCmd.includes(uniquePattern);
|
||||||
|
};
|
||||||
|
|
||||||
// Find and remove from project hooks
|
// Find and remove from project hooks
|
||||||
const projectHooks = hookConfig.project?.hooks?.[template.event];
|
const projectHooks = hookConfig.project?.hooks?.[template.event];
|
||||||
if (projectHooks) {
|
if (projectHooks) {
|
||||||
const hookList = Array.isArray(projectHooks) ? projectHooks : [projectHooks];
|
const hookList = Array.isArray(projectHooks) ? projectHooks : [projectHooks];
|
||||||
const index = hookList.findIndex(h => h.command === template.command);
|
const index = hookList.findIndex(matchesTemplate);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
await removeHook('project', template.event, index);
|
await removeHook('project', template.event, index);
|
||||||
return;
|
return;
|
||||||
@@ -521,12 +546,14 @@ async function uninstallHookTemplate(templateId) {
|
|||||||
const globalHooks = hookConfig.global?.hooks?.[template.event];
|
const globalHooks = hookConfig.global?.hooks?.[template.event];
|
||||||
if (globalHooks) {
|
if (globalHooks) {
|
||||||
const hookList = Array.isArray(globalHooks) ? globalHooks : [globalHooks];
|
const hookList = Array.isArray(globalHooks) ? globalHooks : [globalHooks];
|
||||||
const index = hookList.findIndex(h => h.command === template.command);
|
const index = hookList.findIndex(matchesTemplate);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
await removeHook('global', template.event, index);
|
await removeHook('global', template.event, index);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showRefreshToast('Hook not found', 'error');
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachHookEventListeners() {
|
function attachHookEventListeners() {
|
||||||
|
|||||||
Reference in New Issue
Block a user