diff --git a/src/shared/SettingsDefaultsManager.ts b/src/shared/SettingsDefaultsManager.ts index 923520ca..c9e1923d 100644 --- a/src/shared/SettingsDefaultsManager.ts +++ b/src/shared/SettingsDefaultsManager.ts @@ -49,6 +49,7 @@ export interface SettingsDefaults { CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: string; CLAUDE_MEM_CONTEXT_SHOW_TERMINAL_OUTPUT: string; CLAUDE_MEM_FOLDER_CLAUDEMD_ENABLED: string; + CLAUDE_MEM_FOLDER_USE_LOCAL_MD: string; // 'true' | 'false' - write to CLAUDE.local.md instead of CLAUDE.md // Process Management CLAUDE_MEM_MAX_CONCURRENT_AGENTS: string; // Max concurrent Claude SDK agent subprocesses (default: 2) // Exclusion Settings @@ -108,6 +109,7 @@ export class SettingsDefaultsManager { CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: 'false', CLAUDE_MEM_CONTEXT_SHOW_TERMINAL_OUTPUT: 'true', CLAUDE_MEM_FOLDER_CLAUDEMD_ENABLED: 'false', + CLAUDE_MEM_FOLDER_USE_LOCAL_MD: 'false', // When true, writes to CLAUDE.local.md instead of CLAUDE.md // Process Management CLAUDE_MEM_MAX_CONCURRENT_AGENTS: '2', // Max concurrent Claude SDK agent subprocesses // Exclusion Settings diff --git a/src/utils/claude-md-utils.ts b/src/utils/claude-md-utils.ts index 98a56615..c9619c61 100644 --- a/src/utils/claude-md-utils.ts +++ b/src/utils/claude-md-utils.ts @@ -1,9 +1,13 @@ /** - * CLAUDE.md File Utilities + * CLAUDE.md / CLAUDE.local.md File Utilities * - * Shared utilities for writing folder-level CLAUDE.md files with + * 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'; @@ -16,6 +20,22 @@ 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). @@ -112,14 +132,16 @@ export function replaceTaggedContent(existingContent: string, newContent: string * * @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): void { +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 claudeMdPath = path.join(folderPath, 'CLAUDE.md'); + 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 @@ -329,9 +351,10 @@ export async function updateFolderClaudeMdFiles( _port: number, projectRoot?: string ): Promise { - // Load settings to get configurable observation limit and exclude list + // 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[] = []; @@ -349,18 +372,18 @@ export async function updateFolderClaudeMdFiles( // See: https://github.com/thedotmack/claude-mem/issues/859 const foldersWithActiveClaudeMd = new Set(); - // First pass: identify folders with actively-used CLAUDE.md files + // 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') { + 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 CLAUDE.md, will skip folder', { folderPath }); + logger.debug('FOLDER_INDEX', 'Detected active context file, will skip folder', { folderPath, basename }); } } @@ -435,20 +458,20 @@ export async function updateFolderClaudeMdFiles( const formatted = formatTimelineForClaudeMd(result.content[0].text); - // Fix for #794: Don't create new CLAUDE.md files if there's no activity + // 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, 'CLAUDE.md'); + 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 CLAUDE.md creation', { folderPath }); + logger.debug('FOLDER_INDEX', 'Skipping empty context file creation', { folderPath, targetFilename }); continue; } - writeClaudeMdToFolder(folderPath, formatted); + writeClaudeMdToFolder(folderPath, formatted, targetFilename); - logger.debug('FOLDER_INDEX', 'Updated CLAUDE.md', { folderPath }); + logger.debug('FOLDER_INDEX', 'Updated context file', { folderPath, targetFilename }); } catch (error) { // Fire-and-forget: log warning but don't fail const err = error as Error; diff --git a/tests/utils/claude-md-utils.test.ts b/tests/utils/claude-md-utils.test.ts index de06b317..c3b1c60e 100644 --- a/tests/utils/claude-md-utils.test.ts +++ b/tests/utils/claude-md-utils.test.ts @@ -37,7 +37,8 @@ import { replaceTaggedContent, formatTimelineForClaudeMd, writeClaudeMdToFolder, - updateFolderClaudeMdFiles + updateFolderClaudeMdFiles, + getTargetFilename } from '../../src/utils/claude-md-utils.js'; let tempDir: string; @@ -1002,3 +1003,113 @@ describe('issue #912 - skip unsafe directories for CLAUDE.md generation', () => expect(fetchMock).not.toHaveBeenCalled(); }); }); + +describe('getTargetFilename', () => { + it('should return CLAUDE.md by default', () => { + const settings = { CLAUDE_MEM_FOLDER_USE_LOCAL_MD: 'false' } as any; + expect(getTargetFilename(settings)).toBe('CLAUDE.md'); + }); + + it('should return CLAUDE.local.md when USE_LOCAL_MD is true', () => { + const settings = { CLAUDE_MEM_FOLDER_USE_LOCAL_MD: 'true' } as any; + expect(getTargetFilename(settings)).toBe('CLAUDE.local.md'); + }); + + it('should return CLAUDE.md when USE_LOCAL_MD is undefined', () => { + const settings = {} as any; + expect(getTargetFilename(settings)).toBe('CLAUDE.md'); + }); +}); + +describe('CLAUDE.local.md support', () => { + it('should write CLAUDE.local.md when targetFilename is specified', () => { + const folderPath = join(tempDir, 'local-md-test'); + mkdirSync(folderPath, { recursive: true }); + const content = '# Recent Activity\n\nTest content'; + + writeClaudeMdToFolder(folderPath, content, 'CLAUDE.local.md'); + + const localMdPath = join(folderPath, 'CLAUDE.local.md'); + const regularMdPath = join(folderPath, 'CLAUDE.md'); + + expect(existsSync(localMdPath)).toBe(true); + expect(existsSync(regularMdPath)).toBe(false); + + const fileContent = readFileSync(localMdPath, 'utf-8'); + expect(fileContent).toContain(''); + expect(fileContent).toContain('Test content'); + expect(fileContent).toContain(''); + }); + + it('should preserve user content in CLAUDE.local.md outside tags', () => { + const folderPath = join(tempDir, 'local-preserve-test'); + mkdirSync(folderPath, { recursive: true }); + + const localMdPath = join(folderPath, 'CLAUDE.local.md'); + const userContent = 'My personal notes\n\nOld content\n\nMore notes'; + writeFileSync(localMdPath, userContent); + + writeClaudeMdToFolder(folderPath, 'New generated content', 'CLAUDE.local.md'); + + const fileContent = readFileSync(localMdPath, 'utf-8'); + expect(fileContent).toContain('My personal notes'); + expect(fileContent).toContain('New generated content'); + expect(fileContent).toContain('More notes'); + expect(fileContent).not.toContain('Old content'); + }); + + it('should not leave .tmp file after writing CLAUDE.local.md', () => { + const folderPath = join(tempDir, 'local-atomic-test'); + mkdirSync(folderPath, { recursive: true }); + + writeClaudeMdToFolder(folderPath, 'Atomic write test', 'CLAUDE.local.md'); + + const localMdPath = join(folderPath, 'CLAUDE.local.md'); + const tempFilePath = `${localMdPath}.tmp`; + + expect(existsSync(localMdPath)).toBe(true); + expect(existsSync(tempFilePath)).toBe(false); + }); + + it('should skip folder when CLAUDE.local.md was read in observation', async () => { + const fetchMock = mock(() => Promise.resolve({ ok: true } as Response)); + global.fetch = fetchMock; + + await updateFolderClaudeMdFiles( + ['/project/src/utils/CLAUDE.local.md'], + 'test-project', + 37777, + '/project' + ); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('should skip folder when either CLAUDE.md or CLAUDE.local.md was read', async () => { + const apiResponse = { + content: [{ text: '| #123 | 4:30 PM | 🔵 | Test | ~100 |' }] + }; + const fetchMock = mock(() => Promise.resolve({ + ok: true, + json: () => Promise.resolve(apiResponse) + } as Response)); + global.fetch = fetchMock; + + await updateFolderClaudeMdFiles( + [ + '/project/src/a/CLAUDE.md', // Skip folder a (regular) + '/project/src/b/CLAUDE.local.md', // Skip folder b (local) + '/project/src/c/file.ts' // Process folder c + ], + 'test-project', + 37777, + '/project' + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const callUrl = (fetchMock.mock.calls[0] as unknown[])[0] as string; + expect(callUrl).toContain(encodeURIComponent('/project/src/c')); + expect(callUrl).not.toContain(encodeURIComponent('/project/src/a')); + expect(callUrl).not.toContain(encodeURIComponent('/project/src/b')); + }); +});