mirror of
https://github.com/thedotmack/claude-mem
synced 2026-04-25 17:15:04 +02:00
Add CLAUDE.local.md support via CLAUDE_MEM_FOLDER_USE_LOCAL_MD setting
When CLAUDE_MEM_FOLDER_USE_LOCAL_MD is set to 'true' in settings, claude-mem writes auto-generated context to CLAUDE.local.md instead of CLAUDE.md. This separates personal machine-generated context from shared project instructions, aligning with Claude Code's native CLAUDE.local.md convention where: - CLAUDE.md = team-shared project instructions (checked into git) - CLAUDE.local.md = personal/local context (gitignored) Changes: - Add CLAUDE_MEM_FOLDER_USE_LOCAL_MD setting (default: false) - Add getTargetFilename() helper to resolve target based on settings - Update writeClaudeMdToFolder() to accept optional target filename - Update active-file detection to skip folders with either CLAUDE.md or CLAUDE.local.md being actively read/modified (issue #859 compat) - Add 8 new tests covering filename selection, write behavior, content preservation, atomic writes, and active-file detection Closes #632
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
* <claude-mem-context> 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<typeof SettingsDefaultsManager.loadFromFile>): 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<void> {
|
||||
// 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<string>();
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -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('<claude-mem-context>');
|
||||
expect(fileContent).toContain('Test content');
|
||||
expect(fileContent).toContain('</claude-mem-context>');
|
||||
});
|
||||
|
||||
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<claude-mem-context>\nOld content\n</claude-mem-context>\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'));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user