diff --git a/Auto Run Docs/PR-Triage/PR-Triage-12.md b/Auto Run Docs/PR-Triage/PR-Triage-12.md index b359cb0a..449ec792 100644 --- a/Auto Run Docs/PR-Triage/PR-Triage-12.md +++ b/Auto Run Docs/PR-Triage/PR-Triage-12.md @@ -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 diff --git a/src/cli/handlers/observation.ts b/src/cli/handlers/observation.ts index 482aec37..52b88201 100644 --- a/src/cli/handlers/observation.ts +++ b/src/cli/handlers/observation.ts @@ -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 { @@ -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', diff --git a/src/cli/handlers/session-init.ts b/src/cli/handlers/session-init.ts index 938eb947..d6e06111 100644 --- a/src/cli/handlers/session-init.ts +++ b/src/cli/handlers/session-init.ts @@ -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 { @@ -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; diff --git a/src/shared/SettingsDefaultsManager.ts b/src/shared/SettingsDefaultsManager.ts index 0f386cb2..24089511 100644 --- a/src/shared/SettingsDefaultsManager.ts +++ b/src/shared/SettingsDefaultsManager.ts @@ -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 }; /** diff --git a/src/ui/viewer/constants/settings.ts b/src/ui/viewer/constants/settings.ts index b0fea2ff..f56c44f8 100644 --- a/src/ui/viewer/constants/settings.ts +++ b/src/ui/viewer/constants/settings.ts @@ -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; diff --git a/src/utils/claude-md-utils.ts b/src/utils/claude-md-utils.ts index f04ea210..fafdbc1f 100644 --- a/src/utils/claude-md-utils.ts +++ b/src/utils/claude-md-utils.ts @@ -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 { - // 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); } } diff --git a/src/utils/project-filter.ts b/src/utils/project-filter.ts new file mode 100644 index 00000000..1162d5b0 --- /dev/null +++ b/src/utils/project-filter.ts @@ -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, '<<>>') // Temporary placeholder + .replace(/\*/g, '[^/]*') // * = any non-slash + .replace(/\?/g, '[^/]') // ? = single non-slash + .replace(/<<>>/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; +} diff --git a/tests/utils/project-filter.test.ts b/tests/utils/project-filter.test.ts new file mode 100644 index 00000000..80cf3fb4 --- /dev/null +++ b/tests/utils/project-filter.test.ts @@ -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); + }); + }); + }); +});