diff --git a/ccw/src/core/claude-freshness.ts b/ccw/src/core/claude-freshness.ts new file mode 100644 index 00000000..945c7893 --- /dev/null +++ b/ccw/src/core/claude-freshness.ts @@ -0,0 +1,319 @@ +/** + * CLAUDE.md Freshness Calculator + * Calculates freshness scores based on git changes since last update + */ + +import { execSync } from 'child_process'; +import { existsSync, statSync, readdirSync } from 'fs'; +import { dirname, extname, relative, join } from 'path'; +import { getCoreMemoryStore, ClaudeUpdateRecord } from './core-memory-store.js'; + +// Source file extensions to track (from detect-changed-modules.ts) +const SOURCE_EXTENSIONS = [ + '.md', '.js', '.ts', '.jsx', '.tsx', + '.py', '.go', '.rs', '.java', '.cpp', '.c', '.h', + '.sh', '.ps1', '.json', '.yaml', '.yml' +]; + +// Directories to exclude +const EXCLUDE_DIRS = [ + '.git', '__pycache__', 'node_modules', '.venv', 'venv', 'env', + 'dist', 'build', '.cache', '.pytest_cache', '.mypy_cache', + 'coverage', '.nyc_output', 'logs', 'tmp', 'temp', '.ccw', '.workflow' +]; + +export interface FreshnessResult { + path: string; + level: 'user' | 'project' | 'module'; + relativePath: string; + parentDirectory?: string; + lastUpdated: string | null; + lastModified: string; + changedFilesCount: number; + freshness: number; + updateSource?: string; + needsUpdate: boolean; + changedFiles?: string[]; +} + +export interface FreshnessSummary { + totalFiles: number; + staleCount: number; + averageFreshness: number; + lastScanAt: string; +} + +export interface FreshnessResponse { + files: FreshnessResult[]; + summary: FreshnessSummary; +} + +/** + * Check if git is available and we're in a repo + */ +function isGitRepo(basePath: string): boolean { + try { + execSync('git rev-parse --git-dir', { cwd: basePath, stdio: 'pipe' }); + return true; + } catch (e) { + return false; + } +} + +/** + * Get current git commit hash + */ +export function getCurrentGitCommit(basePath: string): string | null { + try { + const output = execSync('git rev-parse HEAD', { + cwd: basePath, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'] + }).trim(); + return output || null; + } catch (e) { + return null; + } +} + +/** + * Get files changed since a specific date within a directory + */ +function getChangedFilesSince(basePath: string, modulePath: string, sinceDate: string): string[] { + try { + // Format date for git + const date = new Date(sinceDate); + const formattedDate = date.toISOString().split('T')[0]; + + // Get files changed since the date + const output = execSync( + `git log --name-only --since="${formattedDate}" --pretty=format: -- "${modulePath}"`, + { + cwd: basePath, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'] + } + ).trim(); + + if (!output) return []; + + // Get unique files and filter by source extensions + const files = [...new Set(output.split('\n').filter(f => f.trim()))]; + return files.filter(f => { + const ext = extname(f).toLowerCase(); + return SOURCE_EXTENSIONS.includes(ext); + }); + } catch (e) { + // Fallback to mtime-based detection + return findFilesModifiedSince(modulePath, sinceDate); + } +} + +/** + * Fallback: Find files modified since a date using mtime + */ +function findFilesModifiedSince(dirPath: string, sinceDate: string): string[] { + const results: string[] = []; + const cutoffTime = new Date(sinceDate).getTime(); + + function scan(currentPath: string): void { + try { + const entries = readdirSync(currentPath, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isDirectory()) { + if (EXCLUDE_DIRS.includes(entry.name)) continue; + scan(join(currentPath, entry.name)); + } else if (entry.isFile()) { + const ext = extname(entry.name).toLowerCase(); + if (!SOURCE_EXTENSIONS.includes(ext)) continue; + + const fullPath = join(currentPath, entry.name); + try { + const stat = statSync(fullPath); + if (stat.mtimeMs > cutoffTime) { + results.push(relative(dirPath, fullPath)); + } + } catch (e) { + // Skip files we can't stat + } + } + } + } catch (e) { + // Ignore permission errors + } + } + + if (existsSync(dirPath)) { + scan(dirPath); + } + return results; +} + +/** + * Calculate freshness for a single CLAUDE.md file + */ +export function calculateFreshness( + filePath: string, + fileLevel: 'user' | 'project' | 'module', + lastUpdateTime: string | null, + lastModified: string, + projectPath: string, + threshold: number = 20 +): FreshnessResult { + // Use lastUpdateTime from history, or fall back to file mtime + const effectiveUpdateTime = lastUpdateTime || lastModified; + + // Calculate module path for change detection + let modulePath: string | null = null; + let changedFiles: string[] = []; + + if (fileLevel === 'module') { + // For module-level files, scan the parent directory + modulePath = dirname(filePath); + } else if (fileLevel === 'project') { + // For project-level files, scan the project root + modulePath = projectPath; + } + + // Only calculate changes for module/project level in git repos + if (modulePath && isGitRepo(projectPath)) { + changedFiles = getChangedFilesSince(projectPath, modulePath, effectiveUpdateTime); + // Exclude the CLAUDE.md file itself + changedFiles = changedFiles.filter(f => !f.endsWith('CLAUDE.md')); + } + + // Calculate freshness percentage + const changedCount = changedFiles.length; + const freshness = Math.max(0, 100 - Math.floor((changedCount / threshold) * 100)); + + // Determine parent directory for display + const parentDirectory = fileLevel === 'module' + ? filePath.split(/[\\/]/).slice(-2, -1)[0] + : undefined; + + return { + path: filePath, + level: fileLevel, + relativePath: relative(projectPath, filePath).replace(/\\/g, '/'), + parentDirectory, + lastUpdated: lastUpdateTime, + lastModified, + changedFilesCount: changedCount, + freshness, + needsUpdate: freshness < 50, + changedFiles: changedFiles.slice(0, 20) // Limit to first 20 for detail view + }; +} + +/** + * Calculate freshness for all CLAUDE.md files in a project + */ +export function calculateAllFreshness( + claudeFiles: Array<{ + path: string; + level: 'user' | 'project' | 'module'; + lastModified: string; + }>, + projectPath: string, + threshold: number = 20 +): FreshnessResponse { + // Get update records from store + const store = getCoreMemoryStore(projectPath); + const updateRecords = store.getAllClaudeUpdateRecords(); + + // Create a map for quick lookup + const updateMap = new Map(); + for (const record of updateRecords) { + updateMap.set(record.file_path, record); + } + + const results: FreshnessResult[] = []; + + for (const file of claudeFiles) { + const updateRecord = updateMap.get(file.path); + + const result = calculateFreshness( + file.path, + file.level, + updateRecord?.updated_at || null, + file.lastModified, + projectPath, + threshold + ); + + if (updateRecord) { + result.updateSource = updateRecord.update_source; + } + + results.push(result); + } + + // Calculate summary + const staleCount = results.filter(r => r.needsUpdate).length; + const totalFreshness = results.reduce((sum, r) => sum + r.freshness, 0); + const averageFreshness = results.length > 0 ? Math.round(totalFreshness / results.length) : 100; + + return { + files: results, + summary: { + totalFiles: results.length, + staleCount, + averageFreshness, + lastScanAt: new Date().toISOString() + } + }; +} + +/** + * Mark a CLAUDE.md file as updated + */ +export function markFileAsUpdated( + filePath: string, + fileLevel: 'user' | 'project' | 'module', + updateSource: 'manual' | 'cli_sync' | 'dashboard' | 'api', + projectPath: string, + metadata?: object +): ClaudeUpdateRecord { + const store = getCoreMemoryStore(projectPath); + const now = new Date().toISOString(); + + // Get current git commit + const gitCommit = getCurrentGitCommit(projectPath); + + // Calculate changed files count before this update + const lastUpdate = store.getLastClaudeUpdate(filePath); + let filesChangedCount = 0; + + if (lastUpdate && isGitRepo(projectPath)) { + const modulePath = fileLevel === 'module' ? dirname(filePath) : projectPath; + const changedFiles = getChangedFilesSince(projectPath, modulePath, lastUpdate.updated_at); + filesChangedCount = changedFiles.filter(f => !f.endsWith('CLAUDE.md')).length; + } + + // Insert update record + const record = store.insertClaudeUpdateRecord({ + file_path: filePath, + file_level: fileLevel, + module_path: fileLevel === 'module' ? dirname(filePath) : undefined, + updated_at: now, + update_source: updateSource, + git_commit_hash: gitCommit || undefined, + files_changed_before_update: filesChangedCount, + metadata: metadata ? JSON.stringify(metadata) : undefined + }); + + return record; +} + +/** + * Get update history for a file + */ +export function getUpdateHistory( + filePath: string, + projectPath: string, + limit: number = 50 +): ClaudeUpdateRecord[] { + const store = getCoreMemoryStore(projectPath); + return store.getClaudeUpdateHistory(filePath, limit); +} diff --git a/ccw/src/core/core-memory-store.ts b/ccw/src/core/core-memory-store.ts index 914f4f9a..6d4f6ebc 100644 --- a/ccw/src/core/core-memory-store.ts +++ b/ccw/src/core/core-memory-store.ts @@ -71,6 +71,18 @@ export interface MemoryChunk { created_at: string; } +export interface ClaudeUpdateRecord { + id?: number; + file_path: string; + file_level: 'user' | 'project' | 'module'; + module_path?: string; + updated_at: string; + update_source: 'manual' | 'cli_sync' | 'dashboard' | 'api'; + git_commit_hash?: string; + files_changed_before_update: number; + metadata?: string; +} + /** * Core Memory Store using SQLite */ @@ -176,6 +188,20 @@ export class CoreMemoryStore { UNIQUE(source_id, chunk_index) ); + -- CLAUDE.md update history table + CREATE TABLE IF NOT EXISTS claude_update_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_path TEXT NOT NULL, + file_level TEXT NOT NULL CHECK(file_level IN ('user', 'project', 'module')), + module_path TEXT, + updated_at TEXT NOT NULL, + update_source TEXT NOT NULL CHECK(update_source IN ('manual', 'cli_sync', 'dashboard', 'api')), + git_commit_hash TEXT, + files_changed_before_update INTEGER DEFAULT 0, + metadata TEXT, + UNIQUE(file_path, updated_at) + ); + -- Indexes for efficient queries CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(created_at DESC); CREATE INDEX IF NOT EXISTS idx_memories_updated ON memories(updated_at DESC); @@ -186,6 +212,9 @@ export class CoreMemoryStore { CREATE INDEX IF NOT EXISTS idx_session_metadata_type ON session_metadata_cache(session_type); CREATE INDEX IF NOT EXISTS idx_memory_chunks_source ON memory_chunks(source_id, source_type); CREATE INDEX IF NOT EXISTS idx_memory_chunks_embedded ON memory_chunks(embedding IS NOT NULL); + CREATE INDEX IF NOT EXISTS idx_claude_history_path ON claude_update_history(file_path); + CREATE INDEX IF NOT EXISTS idx_claude_history_updated ON claude_update_history(updated_at DESC); + CREATE INDEX IF NOT EXISTS idx_claude_history_module ON claude_update_history(module_path); `); } @@ -1078,6 +1107,128 @@ ${memory.content} stmt.run(sourceId); } + // ============================================================================ + // CLAUDE.md Update History CRUD Operations + // ============================================================================ + + /** + * Insert a CLAUDE.md update record + */ + insertClaudeUpdateRecord(record: Omit): ClaudeUpdateRecord { + const stmt = this.db.prepare(` + INSERT INTO claude_update_history + (file_path, file_level, module_path, updated_at, update_source, git_commit_hash, files_changed_before_update, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + + const result = stmt.run( + record.file_path, + record.file_level, + record.module_path || null, + record.updated_at, + record.update_source, + record.git_commit_hash || null, + record.files_changed_before_update, + record.metadata || null + ); + + return { + id: result.lastInsertRowid as number, + ...record + }; + } + + /** + * Get the last update record for a file + */ + getLastClaudeUpdate(filePath: string): ClaudeUpdateRecord | null { + const stmt = this.db.prepare(` + SELECT * FROM claude_update_history + WHERE file_path = ? + ORDER BY updated_at DESC + LIMIT 1 + `); + + const row = stmt.get(filePath) as any; + if (!row) return null; + + return { + id: row.id, + file_path: row.file_path, + file_level: row.file_level, + module_path: row.module_path, + updated_at: row.updated_at, + update_source: row.update_source, + git_commit_hash: row.git_commit_hash, + files_changed_before_update: row.files_changed_before_update, + metadata: row.metadata + }; + } + + /** + * Get update history for a file + */ + getClaudeUpdateHistory(filePath: string, limit: number = 50): ClaudeUpdateRecord[] { + const stmt = this.db.prepare(` + SELECT * FROM claude_update_history + WHERE file_path = ? + ORDER BY updated_at DESC + LIMIT ? + `); + + const rows = stmt.all(filePath, limit) as any[]; + return rows.map(row => ({ + id: row.id, + file_path: row.file_path, + file_level: row.file_level, + module_path: row.module_path, + updated_at: row.updated_at, + update_source: row.update_source, + git_commit_hash: row.git_commit_hash, + files_changed_before_update: row.files_changed_before_update, + metadata: row.metadata + })); + } + + /** + * Get all CLAUDE.md update records for freshness calculation + */ + getAllClaudeUpdateRecords(): ClaudeUpdateRecord[] { + const stmt = this.db.prepare(` + SELECT * FROM claude_update_history + WHERE id IN ( + SELECT MAX(id) FROM claude_update_history + GROUP BY file_path + ) + ORDER BY updated_at DESC + `); + + const rows = stmt.all() as any[]; + return rows.map(row => ({ + id: row.id, + file_path: row.file_path, + file_level: row.file_level, + module_path: row.module_path, + updated_at: row.updated_at, + update_source: row.update_source, + git_commit_hash: row.git_commit_hash, + files_changed_before_update: row.files_changed_before_update, + metadata: row.metadata + })); + } + + /** + * Delete update records for a file + */ + deleteClaudeUpdateRecords(filePath: string): number { + const stmt = this.db.prepare(` + DELETE FROM claude_update_history + WHERE file_path = ? + `); + const result = stmt.run(filePath); + return result.changes; + } + /** * Close database connection */ diff --git a/ccw/src/core/routes/claude-routes.ts b/ccw/src/core/routes/claude-routes.ts index 3dad1853..5b90ecbf 100644 --- a/ccw/src/core/routes/claude-routes.ts +++ b/ccw/src/core/routes/claude-routes.ts @@ -651,6 +651,14 @@ export async function handleClaudeRoutes(ctx: RouteContext): Promise { // Write updated content writeFileSync(filePath, finalContent, 'utf8'); + // Mark file as updated for freshness tracking + try { + const { markFileAsUpdated } = await import('../claude-freshness.js'); + markFileAsUpdated(filePath, level, 'cli_sync', initialPath, { tool, mode }); + } catch (e) { + console.error('Failed to mark file as updated:', e); + } + // Broadcast WebSocket event broadcastToClients({ type: 'CLAUDE_FILE_SYNCED', @@ -1026,5 +1034,150 @@ export async function handleClaudeRoutes(ctx: RouteContext): Promise { return true; } + // API: Get freshness scores for all CLAUDE.md files + if (pathname === '/api/memory/claude/freshness' && req.method === 'GET') { + try { + const { calculateAllFreshness } = await import('../claude-freshness.js'); + + const projectPathParam = url.searchParams.get('path') || initialPath; + const threshold = parseInt(url.searchParams.get('threshold') || '20', 10); + + // Get all CLAUDE.md files + const filesData = scanAllClaudeFiles(projectPathParam); + + // Prepare file list for freshness calculation + const claudeFiles: Array<{ + path: string; + level: 'user' | 'project' | 'module'; + lastModified: string; + }> = []; + + if (filesData.user.main) { + claudeFiles.push({ + path: filesData.user.main.path, + level: 'user', + lastModified: filesData.user.main.lastModified + }); + } + + if (filesData.project.main) { + claudeFiles.push({ + path: filesData.project.main.path, + level: 'project', + lastModified: filesData.project.main.lastModified + }); + } + + for (const module of filesData.modules) { + claudeFiles.push({ + path: module.path, + level: 'module', + lastModified: module.lastModified + }); + } + + // Calculate freshness + const freshnessData = calculateAllFreshness(claudeFiles, projectPathParam, threshold); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(freshnessData)); + return true; + } catch (error) { + console.error('Error calculating freshness:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (error as Error).message })); + return true; + } + } + + // API: Mark a CLAUDE.md file as updated + if (pathname === '/api/memory/claude/mark-updated' && req.method === 'POST') { + handlePostRequest(req, res, async (body: any) => { + const { path: filePath, source, metadata } = body; + + if (!filePath) { + return { error: 'Missing path parameter', status: 400 }; + } + + if (!source || !['manual', 'cli_sync', 'dashboard', 'api'].includes(source)) { + return { error: 'Invalid or missing source parameter', status: 400 }; + } + + try { + const { markFileAsUpdated } = await import('../claude-freshness.js'); + + // Determine file level + let level: 'user' | 'project' | 'module' = 'module'; + if (filePath.includes(join(homedir(), '.claude'))) { + level = 'user'; + } else if (filePath.includes('.claude')) { + level = 'project'; + } + + const record = markFileAsUpdated(filePath, level, source, initialPath, metadata); + + // Broadcast update + broadcastToClients({ + type: 'CLAUDE_FRESHNESS_UPDATED', + data: { + path: filePath, + level, + updatedAt: record.updated_at, + source + } + }); + + return { + success: true, + record: { + id: record.id, + updated_at: record.updated_at, + filesChangedBeforeUpdate: record.files_changed_before_update + } + }; + } catch (error) { + console.error('Error marking file as updated:', error); + return { error: (error as Error).message, status: 500 }; + } + }); + return true; + } + + // API: Get update history for a CLAUDE.md file + if (pathname === '/api/memory/claude/history' && req.method === 'GET') { + const filePath = url.searchParams.get('path'); + const limit = parseInt(url.searchParams.get('limit') || '50', 10); + + if (!filePath) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Missing path parameter' })); + return true; + } + + try { + const { getUpdateHistory } = await import('../claude-freshness.js'); + + const records = getUpdateHistory(filePath, initialPath, limit); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + records: records.map(r => ({ + id: r.id, + updated_at: r.updated_at, + update_source: r.update_source, + git_commit_hash: r.git_commit_hash, + files_changed_before_update: r.files_changed_before_update, + metadata: r.metadata ? JSON.parse(r.metadata) : undefined + })) + })); + return true; + } catch (error) { + console.error('Error getting update history:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (error as Error).message })); + return true; + } + } + return false; } diff --git a/ccw/src/mcp-server/index.ts b/ccw/src/mcp-server/index.ts index 94640276..8fef0b96 100644 --- a/ccw/src/mcp-server/index.ts +++ b/ccw/src/mcp-server/index.ts @@ -14,7 +14,7 @@ import { getAllToolSchemas, executeTool, executeToolWithProgress } from '../tool import type { ToolSchema, ToolResult } from '../types/tool.js'; const SERVER_NAME = 'ccw-tools'; -const SERVER_VERSION = '6.1.4'; +const SERVER_VERSION = '6.2.0'; // Default enabled tools (core set) const DEFAULT_TOOLS: string[] = ['write_file', 'edit_file', 'read_file', 'smart_search', 'core_memory']; diff --git a/ccw/src/templates/dashboard-css/26-claude-manager.css b/ccw/src/templates/dashboard-css/26-claude-manager.css index d0abc2b0..8f15c94a 100644 --- a/ccw/src/templates/dashboard-css/26-claude-manager.css +++ b/ccw/src/templates/dashboard-css/26-claude-manager.css @@ -734,6 +734,118 @@ border-color: hsl(0, 72%, 45%); } +/* ======================================== + * Freshness Tracking Styles + * ======================================== */ + +/* Freshness badges in file tree */ +.freshness-badge { + font-size: 0.65rem; + padding: 1px 4px; + border-radius: 3px; + margin-left: auto; + font-weight: 500; + flex-shrink: 0; +} + +.freshness-badge.good { + background: hsl(142, 71%, 45%, 0.15); + color: hsl(142, 71%, 45%); +} + +.freshness-badge.warn { + background: hsl(38, 92%, 50%, 0.15); + color: hsl(38, 92%, 50%); +} + +.freshness-badge.stale { + background: hsl(0, 72%, 51%, 0.15); + color: hsl(0, 72%, 51%); +} + +/* File tree item freshness states */ +.file-tree-item.freshness-stale { + border-left: 2px solid hsl(0, 72%, 51%); +} + +.file-tree-item.freshness-warn { + border-left: 2px solid hsl(38, 92%, 50%); +} + +.file-tree-item.freshness-good { + border-left: 2px solid hsl(142, 71%, 45%); +} + +/* Freshness section in metadata panel */ +.freshness-section { + padding: 1rem; + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + background: hsl(var(--card)); +} + +.freshness-section h4 { + display: flex; + align-items: center; + gap: 0.5rem; +} + +/* Freshness gauge/progress bar */ +.freshness-gauge { + position: relative; + height: 8px; + background: hsl(var(--muted)); + border-radius: 4px; + overflow: hidden; + margin: 0.75rem 0; +} + +.freshness-bar { + height: 100%; + border-radius: 4px; + transition: width 0.3s ease; +} + +.freshness-bar.good { + background: linear-gradient(90deg, hsl(142, 71%, 45%), hsl(142, 71%, 55%)); +} + +.freshness-bar.warn { + background: linear-gradient(90deg, hsl(38, 92%, 50%), hsl(45, 92%, 50%)); +} + +.freshness-bar.stale { + background: linear-gradient(90deg, hsl(0, 72%, 51%), hsl(15, 72%, 51%)); +} + +/* Freshness value display */ +.freshness-value-display { + text-align: center; + font-size: 1.5rem; + font-weight: 700; + color: hsl(var(--foreground)); + margin-bottom: 0.75rem; +} + +/* Update reminder warning */ +.update-reminder { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem; + background: hsl(38, 92%, 50%, 0.1); + border: 1px solid hsl(38, 92%, 50%, 0.3); + border-radius: 0.375rem; + color: hsl(38, 92%, 40%); + font-size: 0.8rem; + margin: 0.75rem 0; +} + +.update-reminder i { + flex-shrink: 0; + color: hsl(38, 92%, 50%); +} + /* ======================================== * Responsive Design * ======================================== */ diff --git a/ccw/src/templates/dashboard-js/i18n.js b/ccw/src/templates/dashboard-js/i18n.js index 14b8cc5e..5dcc4a5f 100644 --- a/ccw/src/templates/dashboard-js/i18n.js +++ b/ccw/src/templates/dashboard-js/i18n.js @@ -1087,6 +1087,15 @@ const i18n = { 'claudeManager.unsavedChanges': 'You have unsaved changes. Discard them?', 'claudeManager.saved': 'File saved successfully', 'claudeManager.saveError': 'Failed to save file', + 'claudeManager.freshness': 'Freshness', + 'claudeManager.lastContentUpdate': 'Last Content Update', + 'claudeManager.changedFiles': 'Changed Files', + 'claudeManager.filesSinceUpdate': 'files since update', + 'claudeManager.updateReminder': 'This file may need updating', + 'claudeManager.markAsUpdated': 'Mark as Updated', + 'claudeManager.markedAsUpdated': 'Marked as updated successfully', + 'claudeManager.markUpdateError': 'Failed to mark as updated', + 'claudeManager.never': 'Never tracked', // Graph Explorer 'nav.graphExplorer': 'Graph', @@ -2377,6 +2386,15 @@ const i18n = { 'claudeManager.unsavedChanges': '您有未保存的更改。是否放弃?', 'claudeManager.saved': '文件保存成功', 'claudeManager.saveError': '文件保存失败', + 'claudeManager.freshness': '新鲜度', + 'claudeManager.lastContentUpdate': '上次内容更新', + 'claudeManager.changedFiles': '变动文件', + 'claudeManager.filesSinceUpdate': '个文件自上次更新后变动', + 'claudeManager.updateReminder': '此文件可能需要更新', + 'claudeManager.markAsUpdated': '标记为已更新', + 'claudeManager.markedAsUpdated': '已成功标记为已更新', + 'claudeManager.markUpdateError': '标记更新失败', + 'claudeManager.never': '从未追踪', // Graph Explorer 'nav.graphExplorer': '图谱', diff --git a/ccw/src/templates/dashboard-js/views/claude-manager.js b/ccw/src/templates/dashboard-js/views/claude-manager.js index b19e979b..24eaa28a 100644 --- a/ccw/src/templates/dashboard-js/views/claude-manager.js +++ b/ccw/src/templates/dashboard-js/views/claude-manager.js @@ -17,6 +17,8 @@ var fileTreeExpanded = { modules: {} }; var searchQuery = ''; +var freshnessData = {}; // { [filePath]: FreshnessResult } +var freshnessSummary = null; // ========== Main Render Function ========== async function renderClaudeManager() { @@ -37,6 +39,7 @@ async function renderClaudeManager() { // Load data await loadClaudeFiles(); + await loadFreshnessData(); // Render layout container.innerHTML = '
' + @@ -85,10 +88,60 @@ async function loadClaudeFiles() { async function refreshClaudeFiles() { await loadClaudeFiles(); + await loadFreshnessData(); await renderClaudeManager(); addGlobalNotification('success', t('claudeManager.refreshed'), null, 'CLAUDE.md'); } +// ========== Freshness Data Loading ========== +async function loadFreshnessData() { + try { + var res = await fetch('/api/memory/claude/freshness?path=' + encodeURIComponent(projectPath || '')); + if (!res.ok) throw new Error('Failed to load freshness data'); + var data = await res.json(); + + // Build lookup map + freshnessData = {}; + if (data.files) { + data.files.forEach(function(f) { + freshnessData[f.path] = f; + }); + } + freshnessSummary = data.summary || null; + } catch (error) { + console.error('Error loading freshness data:', error); + freshnessData = {}; + freshnessSummary = null; + } +} + +async function markFileAsUpdated() { + if (!selectedFile) return; + + try { + var res = await fetch('/api/memory/claude/mark-updated', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + path: selectedFile.path, + source: 'dashboard' + }) + }); + + if (!res.ok) throw new Error('Failed to mark file as updated'); + + addGlobalNotification('success', t('claudeManager.markedAsUpdated') || 'Marked as updated', null, 'CLAUDE.md'); + + // Reload freshness data + await loadFreshnessData(); + renderFileTree(); + renderFileMetadata(); + } catch (error) { + console.error('Error marking file as updated:', error); + addGlobalNotification('error', t('claudeManager.markUpdateError') || 'Failed to mark as updated', null, 'CLAUDE.md'); + } +} + // ========== File Tree Rendering ========== function renderFileTree() { var container = document.getElementById('claude-file-tree'); @@ -183,11 +236,30 @@ function renderFileTreeItem(file, indentLevel) { var indentPx = indentLevel * 1.5; var safeId = file.id.replace(/'/g, "'"); - return '
= 75) { + freshnessClass = ' freshness-good'; + freshnessBadge = '' + fd.freshness + '%'; + } else if (fd.freshness >= 50) { + freshnessClass = ' freshness-warn'; + freshnessBadge = '' + fd.freshness + '%'; + } else { + freshnessClass = ' freshness-stale'; + freshnessBadge = '' + fd.freshness + '%'; + } + } + + return '
' + '' + '' + escapeHtml(file.name) + '' + + freshnessBadge + (file.parentDirectory ? '' + escapeHtml(file.parentDirectory) + '' : '') + '
'; } @@ -446,6 +518,38 @@ function renderFileMetadata() { '
'; } + // Freshness section + var fd = freshnessData[selectedFile.path]; + if (fd) { + var freshnessBarClass = fd.freshness >= 75 ? 'good' : fd.freshness >= 50 ? 'warn' : 'stale'; + html += ''; + } + html += '