/** * CLAUDE.md / CLAUDE.local.md File Utilities * * Shared utilities for writing folder-level context files with * auto-generated context sections. Preserves user content outside * tags. * * When CLAUDE_MEM_FOLDER_USE_LOCAL_MD is 'true', writes to CLAUDE.local.md * instead of CLAUDE.md. This keeps auto-generated context in a personal, * gitignored file separate from shared project instructions. */ import { existsSync, readFileSync, writeFileSync, renameSync } from 'fs'; import path from 'path'; import os from 'os'; import { logger } from './logger.js'; import { formatDate, groupByDate } from '../shared/timeline-formatting.js'; import { SettingsDefaultsManager } from '../shared/SettingsDefaultsManager.js'; import { workerHttpRequest } from '../shared/worker-utils.js'; const SETTINGS_PATH = path.join(os.homedir(), '.claude-mem', 'settings.json'); /** Default target filename */ const CLAUDE_MD_FILENAME = 'CLAUDE.md'; /** Alternative target filename for personal/local context */ const CLAUDE_LOCAL_MD_FILENAME = 'CLAUDE.local.md'; /** * Get the target filename based on settings. * Returns 'CLAUDE.local.md' when CLAUDE_MEM_FOLDER_USE_LOCAL_MD is 'true', * otherwise returns 'CLAUDE.md'. */ export function getTargetFilename(settings?: ReturnType): string { const s = settings ?? SettingsDefaultsManager.loadFromFile(SETTINGS_PATH); return s.CLAUDE_MEM_FOLDER_USE_LOCAL_MD === 'true' ? CLAUDE_LOCAL_MD_FILENAME : CLAUDE_MD_FILENAME; } /** * Check for consecutive duplicate path segments like frontend/frontend/ or src/src/. * This catches paths created when cwd already includes the directory name (Issue #814). * * @param resolvedPath - The resolved absolute path to check * @returns true if consecutive duplicate segments are found */ function hasConsecutiveDuplicateSegments(resolvedPath: string): boolean { const segments = resolvedPath.split(path.sep).filter(s => s && s !== '.' && s !== '..'); for (let i = 1; i < segments.length; i++) { if (segments[i] === segments[i - 1]) return true; } return false; } /** * Validate that a file path is safe for CLAUDE.md generation. * Rejects tilde paths, URLs, command-like strings, and paths with invalid chars. * * @param filePath - The file path to validate * @param projectRoot - Optional project root for boundary checking * @returns true if path is valid for CLAUDE.md processing */ function isValidPathForClaudeMd(filePath: string, projectRoot?: string): boolean { // Reject empty or whitespace-only if (!filePath || !filePath.trim()) return false; // Reject tilde paths (Node.js doesn't expand ~) if (filePath.startsWith('~')) return false; // Reject URLs if (filePath.startsWith('http://') || filePath.startsWith('https://')) return false; // Reject paths with spaces (likely command text or PR references) if (filePath.includes(' ')) return false; // Reject paths with # (GitHub issue/PR references) if (filePath.includes('#')) return false; // If projectRoot provided, ensure path stays within project boundaries if (projectRoot) { // For relative paths, resolve against projectRoot; for absolute paths, use directly const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(projectRoot, filePath); const normalizedRoot = path.resolve(projectRoot); if (!resolved.startsWith(normalizedRoot + path.sep) && resolved !== normalizedRoot) { return false; } // Reject paths with consecutive duplicate segments (Issue #814) // e.g., frontend/frontend/, backend/backend/, src/src/ if (hasConsecutiveDuplicateSegments(resolved)) { return false; } } return true; } /** * Replace tagged content in existing file, preserving content outside tags. * * Handles three cases: * 1. No existing content → wraps new content in tags * 2. Has existing tags → replaces only tagged section * 3. No tags in existing content → appends tagged content at end */ export function replaceTaggedContent(existingContent: string, newContent: string): string { const startTag = ''; const endTag = ''; // If no existing content, wrap new content in tags if (!existingContent) { return `${startTag}\n${newContent}\n${endTag}`; } // If existing has tags, replace only tagged section const startIdx = existingContent.indexOf(startTag); const endIdx = existingContent.indexOf(endTag); if (startIdx !== -1 && endIdx !== -1) { return existingContent.substring(0, startIdx) + `${startTag}\n${newContent}\n${endTag}` + existingContent.substring(endIdx + endTag.length); } // If no tags exist, append tagged content at end return existingContent + `\n\n${startTag}\n${newContent}\n${endTag}`; } /** * Write CLAUDE.md file to folder with atomic writes. * Only writes to existing folders; skips non-existent paths to prevent * creating spurious directory structures from malformed paths. * * @param folderPath - Absolute path to the folder (must already exist) * @param newContent - Content to write inside tags * @param targetFilename - Target filename (default: determined by settings) */ export function writeClaudeMdToFolder(folderPath: string, newContent: string, targetFilename?: string): void { const resolvedPath = path.resolve(folderPath); // Never write inside .git directories — corrupts refs (#1165) if (resolvedPath.includes('/.git/') || resolvedPath.includes('\\.git\\') || resolvedPath.endsWith('/.git') || resolvedPath.endsWith('\\.git')) return; const filename = targetFilename ?? getTargetFilename(); const claudeMdPath = path.join(folderPath, filename); const tempFile = `${claudeMdPath}.tmp`; // Only write to folders that already exist - never create new directories // This prevents creating spurious folder structures from malformed paths if (!existsSync(folderPath)) { logger.debug('FOLDER_INDEX', 'Skipping non-existent folder', { folderPath }); return; } // Read existing content if file exists let existingContent = ''; if (existsSync(claudeMdPath)) { existingContent = readFileSync(claudeMdPath, 'utf-8'); } // Replace only tagged content, preserve user content const finalContent = replaceTaggedContent(existingContent, newContent); // Atomic write: temp file + rename writeFileSync(tempFile, finalContent); renameSync(tempFile, claudeMdPath); } /** * Parsed observation from API response text */ interface ParsedObservation { id: string; time: string; typeEmoji: string; title: string; tokens: string; epoch: number; // For date grouping } /** * Format timeline text from API response to timeline format. * * Uses the same format as search results: * - Grouped by date (### Jan 4, 2026) * - Grouped by file within each date (**filename**) * - Table with columns: ID, Time, T (type emoji), Title, Read (tokens) * - Ditto marks for repeated times * * @param timelineText - Raw API response text * @returns Formatted markdown with date/file grouping */ export function formatTimelineForClaudeMd(timelineText: string): string { const lines: string[] = []; lines.push('# Recent Activity'); lines.push(''); // Parse the API response to extract observation rows const apiLines = timelineText.split('\n'); // Note: We skip file grouping since we're querying by folder - all results are from the same folder // Parse observations: | #123 | 4:30 PM | 🔧 | Title | ~250 | ... | const observations: ParsedObservation[] = []; let lastTimeStr = ''; let currentDate: Date | null = null; for (const line of apiLines) { // Check for date headers: ### Jan 4, 2026 const dateMatch = line.match(/^###\s+(.+)$/); if (dateMatch) { const dateStr = dateMatch[1].trim(); const parsedDate = new Date(dateStr); // Validate the parsed date if (!isNaN(parsedDate.getTime())) { currentDate = parsedDate; } continue; } // Match table rows: | #123 | 4:30 PM | 🔧 | Title | ~250 | ... | // Also handles ditto marks and session IDs (#S123) const match = line.match(/^\|\s*(#[S]?\d+)\s*\|\s*([^|]+)\s*\|\s*([^|]+)\s*\|\s*([^|]+)\s*\|\s*([^|]+)\s*\|/); if (match) { const [, id, timeStr, typeEmoji, title, tokens] = match; // Handle ditto mark (″) - use last time let time: string; if (timeStr.trim() === '″' || timeStr.trim() === '"') { time = lastTimeStr; } else { time = timeStr.trim(); lastTimeStr = time; } // Parse time and combine with current date header (or fallback to today) const baseDate = currentDate ? new Date(currentDate) : new Date(); const timeParts = time.match(/(\d+):(\d+)\s*(AM|PM)/i); let epoch = baseDate.getTime(); if (timeParts) { let hours = parseInt(timeParts[1], 10); const minutes = parseInt(timeParts[2], 10); const isPM = timeParts[3].toUpperCase() === 'PM'; if (isPM && hours !== 12) hours += 12; if (!isPM && hours === 12) hours = 0; baseDate.setHours(hours, minutes, 0, 0); epoch = baseDate.getTime(); } observations.push({ id: id.trim(), time, typeEmoji: typeEmoji.trim(), title: title.trim(), tokens: tokens.trim(), epoch }); } } if (observations.length === 0) { return ''; } // Group by date const byDate = groupByDate(observations, obs => new Date(obs.epoch).toISOString()); // Render each date group for (const [day, dayObs] of byDate) { lines.push(`### ${day}`); lines.push(''); lines.push('| ID | Time | T | Title | Read |'); lines.push('|----|------|---|-------|------|'); let lastTime = ''; for (const obs of dayObs) { const timeDisplay = obs.time === lastTime ? '"' : obs.time; lastTime = obs.time; lines.push(`| ${obs.id} | ${timeDisplay} | ${obs.typeEmoji} | ${obs.title} | ${obs.tokens} |`); } lines.push(''); } return lines.join('\n').trim(); } /** * Built-in directory names where CLAUDE.md generation is unsafe or undesirable. * e.g. Android res/ is compiler-strict (non-XML breaks build); .git, build, node_modules are tooling-owned. */ const EXCLUDED_UNSAFE_DIRECTORIES = new Set([ 'res', '.git', 'build', 'node_modules', '__pycache__' ]); /** * Returns true if folder path contains any excluded segment (e.g. .../res/..., .../node_modules/...). */ function isExcludedUnsafeDirectory(folderPath: string): boolean { const normalized = path.normalize(folderPath); const segments = normalized.split(path.sep); return segments.some(segment => EXCLUDED_UNSAFE_DIRECTORIES.has(segment)); } /** * Check if a folder is a project root (contains .git directory). * Project root CLAUDE.md files should remain user-managed, not auto-updated. */ function isProjectRoot(folderPath: string): boolean { const gitPath = path.join(folderPath, '.git'); return existsSync(gitPath); } /** * Check if a folder path is excluded from CLAUDE.md generation. * A folder is excluded if it matches or is within any path in the exclude list. * * @param folderPath - Absolute path to check * @param excludePaths - Array of paths to exclude * @returns true if folder should be excluded */ function isExcludedFolder(folderPath: string, excludePaths: string[]): boolean { const normalizedFolder = path.resolve(folderPath); for (const excludePath of excludePaths) { const normalizedExclude = path.resolve(excludePath); if (normalizedFolder === normalizedExclude || normalizedFolder.startsWith(normalizedExclude + path.sep)) { return true; } } return false; } /** * Update CLAUDE.md files for folders containing the given files. * Fetches timeline from worker API and writes formatted content. * * NOTE: Project root folders (containing .git) are excluded to preserve * user-managed root CLAUDE.md files. Only subfolder CLAUDE.md files are auto-updated. * * @param filePaths - Array of absolute file paths (modified or read) * @param project - Project identifier for API query * @param _port - Worker API port (legacy, now resolved automatically via socket/TCP) */ export async function updateFolderClaudeMdFiles( filePaths: string[], project: string, _port: number, projectRoot?: string ): Promise { // Load settings to get configurable observation limit, exclude list, and target filename const settings = SettingsDefaultsManager.loadFromFile(SETTINGS_PATH); const limit = parseInt(settings.CLAUDE_MEM_CONTEXT_OBSERVATIONS, 10) || 50; const targetFilename = getTargetFilename(settings); // Parse exclude paths from settings let folderMdExcludePaths: string[] = []; try { const parsed = JSON.parse(settings.CLAUDE_MEM_FOLDER_MD_EXCLUDE || '[]'); if (Array.isArray(parsed)) { folderMdExcludePaths = parsed.filter((p): p is string => typeof p === 'string'); } } catch { logger.warn('FOLDER_INDEX', 'Failed to parse CLAUDE_MEM_FOLDER_MD_EXCLUDE setting'); } // Track folders containing CLAUDE.md files that were read/modified in this observation. // We must NOT update these - it would cause "file modified since read" errors in Claude Code. // See: https://github.com/thedotmack/claude-mem/issues/859 const foldersWithActiveClaudeMd = new Set(); // First pass: identify folders with actively-used CLAUDE.md or CLAUDE.local.md files for (const filePath of filePaths) { if (!filePath) continue; const basename = path.basename(filePath); if (basename === CLAUDE_MD_FILENAME || basename === CLAUDE_LOCAL_MD_FILENAME) { let absoluteFilePath = filePath; if (projectRoot && !path.isAbsolute(filePath)) { absoluteFilePath = path.join(projectRoot, filePath); } const folderPath = path.dirname(absoluteFilePath); foldersWithActiveClaudeMd.add(folderPath); logger.debug('FOLDER_INDEX', 'Detected active context file, will skip folder', { folderPath, basename }); } } // Extract unique folder paths from file paths const folderPaths = new Set(); for (const filePath of filePaths) { if (!filePath || filePath === '') continue; // VALIDATE PATH BEFORE PROCESSING if (!isValidPathForClaudeMd(filePath, projectRoot)) { logger.debug('FOLDER_INDEX', 'Skipping invalid file path', { filePath, reason: 'Failed path validation' }); continue; } // Resolve relative paths to absolute using projectRoot let absoluteFilePath = filePath; if (projectRoot && !path.isAbsolute(filePath)) { absoluteFilePath = path.join(projectRoot, filePath); } const folderPath = path.dirname(absoluteFilePath); if (folderPath && folderPath !== '.' && folderPath !== '/') { // Skip project root - root CLAUDE.md should remain user-managed if (isProjectRoot(folderPath)) { logger.debug('FOLDER_INDEX', 'Skipping project root CLAUDE.md', { folderPath }); continue; } // Skip known-unsafe directories (e.g. Android res/, .git, build, node_modules) if (isExcludedUnsafeDirectory(folderPath)) { logger.debug('FOLDER_INDEX', 'Skipping unsafe directory for CLAUDE.md', { folderPath }); continue; } // Skip folders where CLAUDE.md was read/modified in this observation (issue #859) if (foldersWithActiveClaudeMd.has(folderPath)) { logger.debug('FOLDER_INDEX', 'Skipping folder with active CLAUDE.md to avoid race condition', { folderPath }); continue; } // Skip folders in user-configured exclude list if (folderMdExcludePaths.length > 0 && isExcludedFolder(folderPath, folderMdExcludePaths)) { logger.debug('FOLDER_INDEX', 'Skipping excluded folder', { folderPath }); continue; } folderPaths.add(folderPath); } } if (folderPaths.size === 0) return; logger.debug('FOLDER_INDEX', 'Updating CLAUDE.md files', { project, folderCount: folderPaths.size }); // Process each folder for (const folderPath of folderPaths) { let response: Response; try { // Fetch timeline via existing API (uses socket or TCP automatically) response = await workerHttpRequest( `/api/search/by-file?filePath=${encodeURIComponent(folderPath)}&limit=${limit}&project=${encodeURIComponent(project)}&isFolder=true` ); } catch (error: unknown) { // Fire-and-forget: log warning but don't fail const message = error instanceof Error ? error.message : String(error); const stack = error instanceof Error ? error.stack : undefined; logger.error('FOLDER_INDEX', `Failed to fetch timeline for ${targetFilename}`, { folderPath, errorMessage: message, errorStack: stack }); continue; } if (!response.ok) { logger.error('FOLDER_INDEX', 'Failed to fetch timeline', { folderPath, status: response.status }); continue; } const result = await response.json() as { content?: Array<{ text?: string }> }; if (!result.content?.[0]?.text) { logger.debug('FOLDER_INDEX', 'No content for folder', { folderPath }); continue; } const formatted = formatTimelineForClaudeMd(result.content[0].text); // Fix for #794: Don't create new context files if there's no activity // But update existing ones to show "No recent activity" if they already exist const claudeMdPath = path.join(folderPath, targetFilename); const hasNoActivity = formatted.includes('*No recent activity*'); const fileExists = existsSync(claudeMdPath); if (hasNoActivity && !fileExists) { logger.debug('FOLDER_INDEX', 'Skipping empty context file creation', { folderPath, targetFilename }); continue; } writeClaudeMdToFolder(folderPath, formatted, targetFilename); logger.debug('FOLDER_INDEX', 'Updated context file', { folderPath, targetFilename }); } }