feat: Add CLAUDE.md freshness tracking and update reminders

- Add SQLite table and CRUD methods for tracking update history
- Create freshness calculation service based on git file changes
- Add API endpoints for freshness data, marking updates, and history
- Display freshness badges in file tree (green/yellow/red indicators)
- Show freshness gauge and details in metadata panel
- Auto-mark files as updated after CLI sync
- Add English and Chinese i18n translations

Freshness algorithm: 100 - min((changedFilesCount / 20) * 100, 100)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
catlog22
2025-12-20 16:14:46 +08:00
parent 4a3ff82200
commit b27d8a9570
18 changed files with 2260 additions and 18 deletions

View File

@@ -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<string, ClaudeUpdateRecord>();
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);
}

View File

@@ -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, 'id'>): 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
*/

View File

@@ -651,6 +651,14 @@ export async function handleClaudeRoutes(ctx: RouteContext): Promise<boolean> {
// 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<boolean> {
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;
}