Cherry-picked both PRs to main (both had merge conflicts with current main). PR #920 (@Spunky84): CLAUDE_MEM_EXCLUDED_PROJECTS setting with glob patterns to exclude entire projects from memory tracking (privacy/confidentiality). Early-exit in session-init and observation handlers. 11 unit tests. PR #699 (@leepokai): CLAUDE_MEM_FOLDER_MD_EXCLUDE setting with JSON array of paths to exclude from CLAUDE.md file generation (fixes SwiftUI/Xcode build conflicts and drizzle kit migration failures). Closes #620. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -22,7 +22,7 @@ Multiple community members want to add provider support. Evaluate whether the pr
|
||||
|
||||
- [x] Evaluate PR #662 (`feat(mcp): add save_memory tool for manual memory storage` by @darconada, 8 files). **MERGED (cherry-picked to main).** Manual memory storage aligns with the project philosophy — automatic capture handles 80% of cases, manual save handles the 20% where users want explicit control (Issue #645). Source changes applied directly (PR had merge conflicts in compiled .cjs build artifacts only). Implementation is clean: MCP tool definition, POST /api/memory/save endpoint via MemoryRoutes.ts, getOrCreateManualSession() in SessionStore, README updates. Minor fix: changed logger component from unregistered 'MEMORY' to 'HTTP'/'CHROMA'. Closes #645.
|
||||
|
||||
- [ ] Evaluate PR #920 (`feat: add project exclusion setting` by @Spunky84, 7 files) and PR #699 (`feat: add folder exclude setting for CLAUDE.md generation` by @leepokai, 2 files). Both add exclusion settings but at different levels (project vs. folder). Decision: (1) Is exclusion needed? Users do complain about CLAUDE.md pollution. (2) PR #699 is smaller (2 files) and more focused. Prefer #699 if only one is needed. If both levels are useful, merge both.
|
||||
- [x] Evaluate PR #920 (`feat: add project exclusion setting` by @Spunky84, 7 files) and PR #699 (`feat: add folder exclude setting for CLAUDE.md generation` by @leepokai, 2 files). **BOTH MERGED (cherry-picked to main).** These solve complementary problems, not the same problem: (1) PR #920 adds `CLAUDE_MEM_EXCLUDED_PROJECTS` — glob patterns to exclude entire projects from ALL tracking (privacy/confidentiality). Well-tested (11 unit tests), clean glob-to-regex implementation, early-exit in session-init and observation handlers. Minor cleanup: removed unused SessionRoutes import and unexported internal helper. (2) PR #699 adds `CLAUDE_MEM_FOLDER_MD_EXCLUDE` — JSON array of paths to exclude from CLAUDE.md file generation (build tool compatibility). Solves confirmed issues: SwiftUI/Xcode build conflicts, drizzle kit migration failures (3 separate commenters). Closes #620. Both had merge conflicts with current main, so changes were applied directly rather than git-merged.
|
||||
|
||||
## Architectural Changes
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js'
|
||||
import { ensureWorkerRunning, getWorkerPort } from '../../shared/worker-utils.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
|
||||
import { isProjectExcluded } from '../../utils/project-filter.js';
|
||||
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
|
||||
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
|
||||
|
||||
export const observationHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
@@ -37,6 +40,13 @@ export const observationHandler: EventHandler = {
|
||||
throw new Error(`Missing cwd in PostToolUse hook input for session ${sessionId}, tool ${toolName}`);
|
||||
}
|
||||
|
||||
// Check if project is excluded from tracking
|
||||
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
|
||||
if (isProjectExcluded(cwd, settings.CLAUDE_MEM_EXCLUDED_PROJECTS)) {
|
||||
logger.debug('HOOK', 'Project excluded from tracking, skipping observation', { cwd, toolName });
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
|
||||
// Send to worker - worker handles privacy check and database operations
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/sessions/observations`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -9,6 +9,9 @@ import { ensureWorkerRunning, getWorkerPort } from '../../shared/worker-utils.js
|
||||
import { getProjectName } from '../../utils/project-name.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
|
||||
import { isProjectExcluded } from '../../utils/project-filter.js';
|
||||
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
|
||||
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
|
||||
|
||||
export const sessionInitHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
@@ -21,6 +24,13 @@ export const sessionInitHandler: EventHandler = {
|
||||
|
||||
const { sessionId, cwd, prompt: rawPrompt } = input;
|
||||
|
||||
// Check if project is excluded from tracking
|
||||
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
|
||||
if (cwd && isProjectExcluded(cwd, settings.CLAUDE_MEM_EXCLUDED_PROJECTS)) {
|
||||
logger.info('HOOK', 'Project excluded from tracking', { cwd });
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
|
||||
// Handle image-only prompts (where text prompt is empty/undefined)
|
||||
// Use placeholder so sessions still get created and tracked for memory
|
||||
const prompt = (!rawPrompt || !rawPrompt.trim()) ? '[media prompt]' : rawPrompt;
|
||||
|
||||
@@ -52,6 +52,9 @@ export interface SettingsDefaults {
|
||||
CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY: string;
|
||||
CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: string;
|
||||
CLAUDE_MEM_FOLDER_CLAUDEMD_ENABLED: string;
|
||||
// Exclusion Settings
|
||||
CLAUDE_MEM_EXCLUDED_PROJECTS: string; // Comma-separated glob patterns for excluded project paths
|
||||
CLAUDE_MEM_FOLDER_MD_EXCLUDE: string; // JSON array of folder paths to exclude from CLAUDE.md generation
|
||||
}
|
||||
|
||||
export class SettingsDefaultsManager {
|
||||
@@ -98,6 +101,9 @@ export class SettingsDefaultsManager {
|
||||
CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY: 'true',
|
||||
CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: 'false',
|
||||
CLAUDE_MEM_FOLDER_CLAUDEMD_ENABLED: 'false',
|
||||
// Exclusion Settings
|
||||
CLAUDE_MEM_EXCLUDED_PROJECTS: '', // Comma-separated glob patterns for excluded project paths
|
||||
CLAUDE_MEM_FOLDER_MD_EXCLUDE: '[]', // JSON array of folder paths to exclude from CLAUDE.md generation
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,4 +36,8 @@ export const DEFAULT_SETTINGS = {
|
||||
// Feature Toggles
|
||||
CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY: 'true',
|
||||
CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: 'false',
|
||||
|
||||
// Exclusion Settings
|
||||
CLAUDE_MEM_EXCLUDED_PROJECTS: '',
|
||||
CLAUDE_MEM_FOLDER_MD_EXCLUDE: '[]',
|
||||
} as const;
|
||||
|
||||
@@ -287,6 +287,26 @@ function isProjectRoot(folderPath: string): boolean {
|
||||
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.
|
||||
@@ -304,10 +324,21 @@ export async function updateFolderClaudeMdFiles(
|
||||
port: number,
|
||||
projectRoot?: string
|
||||
): Promise<void> {
|
||||
// Load settings to get configurable observation limit
|
||||
// Load settings to get configurable observation limit and exclude list
|
||||
const settings = SettingsDefaultsManager.loadFromFile(SETTINGS_PATH);
|
||||
const limit = parseInt(settings.CLAUDE_MEM_CONTEXT_OBSERVATIONS, 10) || 50;
|
||||
|
||||
// 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
|
||||
@@ -362,6 +393,11 @@ export async function updateFolderClaudeMdFiles(
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Project Filter Utility
|
||||
*
|
||||
* Provides glob-based path matching for project exclusion.
|
||||
* Supports: ~ (home), * (any chars except /), ** (any path), ? (single char)
|
||||
*/
|
||||
|
||||
import { homedir } from 'os';
|
||||
|
||||
/**
|
||||
* Convert a glob pattern to a regular expression
|
||||
* Supports: ~ (home dir), * (any non-slash), ** (any path), ? (single char)
|
||||
*/
|
||||
function globToRegex(pattern: string): RegExp {
|
||||
// Expand ~ to home directory
|
||||
let expanded = pattern.startsWith('~')
|
||||
? homedir() + pattern.slice(1)
|
||||
: pattern;
|
||||
|
||||
// Normalize path separators to forward slashes
|
||||
expanded = expanded.replace(/\\/g, '/');
|
||||
|
||||
// Escape regex special characters except * and ?
|
||||
let regex = expanded.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
// Convert glob patterns to regex:
|
||||
// ** matches any path (including /)
|
||||
// * matches any characters except /
|
||||
// ? matches single character except /
|
||||
regex = regex
|
||||
.replace(/\*\*/g, '<<<GLOBSTAR>>>') // Temporary placeholder
|
||||
.replace(/\*/g, '[^/]*') // * = any non-slash
|
||||
.replace(/\?/g, '[^/]') // ? = single non-slash
|
||||
.replace(/<<<GLOBSTAR>>>/g, '.*'); // ** = anything
|
||||
|
||||
return new RegExp(`^${regex}$`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path matches any of the exclusion patterns
|
||||
*
|
||||
* @param projectPath - Current working directory (absolute path)
|
||||
* @param exclusionPatterns - Comma-separated glob patterns (e.g., "~/kunden/*,/tmp/*")
|
||||
* @returns true if path should be excluded
|
||||
*/
|
||||
export function isProjectExcluded(projectPath: string, exclusionPatterns: string): boolean {
|
||||
if (!exclusionPatterns || !exclusionPatterns.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Normalize cwd path separators
|
||||
const normalizedProjectPath = projectPath.replace(/\\/g, '/');
|
||||
|
||||
// Parse comma-separated patterns
|
||||
const patternList = exclusionPatterns
|
||||
.split(',')
|
||||
.map(p => p.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const pattern of patternList) {
|
||||
try {
|
||||
const regex = globToRegex(pattern);
|
||||
if (regex.test(normalizedProjectPath)) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Invalid pattern, skip it
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Project Filter Tests
|
||||
*
|
||||
* Tests glob-based path matching for project exclusion.
|
||||
* Source: src/utils/project-filter.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { isProjectExcluded } from '../../src/utils/project-filter.js';
|
||||
import { homedir } from 'os';
|
||||
|
||||
describe('Project Filter', () => {
|
||||
describe('isProjectExcluded', () => {
|
||||
describe('with empty patterns', () => {
|
||||
it('returns false for empty pattern string', () => {
|
||||
expect(isProjectExcluded('/Users/test/project', '')).toBe(false);
|
||||
expect(isProjectExcluded('/Users/test/project', ' ')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with exact path matching', () => {
|
||||
it('matches exact paths', () => {
|
||||
expect(isProjectExcluded('/tmp/secret', '/tmp/secret')).toBe(true);
|
||||
expect(isProjectExcluded('/tmp/public', '/tmp/secret')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with * wildcard (single directory level)', () => {
|
||||
it('matches any directory name', () => {
|
||||
expect(isProjectExcluded('/tmp/secret', '/tmp/*')).toBe(true);
|
||||
expect(isProjectExcluded('/tmp/anything', '/tmp/*')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match across directory boundaries', () => {
|
||||
expect(isProjectExcluded('/tmp/a/b', '/tmp/*')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with ** wildcard (any path depth)', () => {
|
||||
it('matches any path depth', () => {
|
||||
expect(isProjectExcluded('/Users/test/kunden/client1/project', '/Users/*/kunden/**')).toBe(true);
|
||||
expect(isProjectExcluded('/Users/test/kunden/deep/nested/project', '/Users/*/kunden/**')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with ? wildcard (single character)', () => {
|
||||
it('matches single character', () => {
|
||||
expect(isProjectExcluded('/tmp/a', '/tmp/?')).toBe(true);
|
||||
expect(isProjectExcluded('/tmp/ab', '/tmp/?')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with ~ home directory expansion', () => {
|
||||
it('expands ~ to home directory', () => {
|
||||
const home = homedir();
|
||||
expect(isProjectExcluded(`${home}/secret`, '~/secret')).toBe(true);
|
||||
expect(isProjectExcluded(`${home}/projects/secret`, '~/projects/*')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with multiple patterns', () => {
|
||||
it('returns true if any pattern matches', () => {
|
||||
const patterns = '/tmp/*,~/kunden/*,/var/secret';
|
||||
expect(isProjectExcluded('/tmp/test', patterns)).toBe(true);
|
||||
expect(isProjectExcluded(`${homedir()}/kunden/client`, patterns)).toBe(true);
|
||||
expect(isProjectExcluded('/var/secret', patterns)).toBe(true);
|
||||
expect(isProjectExcluded('/home/user/public', patterns)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with Windows-style paths', () => {
|
||||
it('normalizes backslashes to forward slashes', () => {
|
||||
expect(isProjectExcluded('C:\\Users\\test\\secret', 'C:/Users/*/secret')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('real-world patterns', () => {
|
||||
it('excludes customer projects', () => {
|
||||
const patterns = '~/kunden/*,~/customers/**';
|
||||
const home = homedir();
|
||||
|
||||
expect(isProjectExcluded(`${home}/kunden/acme-corp`, patterns)).toBe(true);
|
||||
expect(isProjectExcluded(`${home}/customers/bigco/project1`, patterns)).toBe(true);
|
||||
expect(isProjectExcluded(`${home}/projects/opensource`, patterns)).toBe(false);
|
||||
});
|
||||
|
||||
it('excludes temporary directories', () => {
|
||||
const patterns = '/tmp/*,/var/tmp/*';
|
||||
|
||||
expect(isProjectExcluded('/tmp/scratch', patterns)).toBe(true);
|
||||
expect(isProjectExcluded('/var/tmp/test', patterns)).toBe(true);
|
||||
expect(isProjectExcluded('/home/user/tmp', patterns)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user