import { feature } from 'bun:bundle' import { normalize, posix, win32 } from 'path' import { getAutoMemPath, getMemoryBaseDir, isAutoMemoryEnabled, isAutoMemPath, } from '../memdir/paths.js' import { isAgentMemoryPath } from '../tools/AgentTool/agentMemory.js' import { getClaudeConfigHomeDir } from './envUtils.js' import { posixPathToWindowsPath, windowsPathToPosixPath, } from './windowsPaths.js' /* eslint-disable @typescript-eslint/no-require-imports */ const teamMemPaths = feature('TEAMMEM') ? (require('../memdir/teamMemPaths.js') as typeof import('../memdir/teamMemPaths.js')) : null /* eslint-enable @typescript-eslint/no-require-imports */ const IS_WINDOWS = process.platform === 'win32' // Normalize path separators to posix (/). Does NOT translate drive encoding. function toPosix(p: string): string { return p.split(win32.sep).join(posix.sep) } // Convert a path to a stable string-comparable form: forward-slash separated, // and on Windows, lowercased (Windows filesystems are case-insensitive). function toComparable(p: string): string { const posixForm = toPosix(p) return IS_WINDOWS ? posixForm.toLowerCase() : posixForm } /** * Detects if a file path is a session-related file under ~/.claude. * Returns the type of session file or null if not a session file. */ export function detectSessionFileType( filePath: string, ): 'session_memory' | 'session_transcript' | null { const configDir = getClaudeConfigHomeDir() // Compare in forward-slash form; on Windows also case-fold. The caller // (isShellCommandTargetingMemory) converts MinGW /c/... → native before // reaching here, so we only need separator + case normalization. const normalized = toComparable(filePath) const configDirCmp = toComparable(configDir) if (!normalized.startsWith(configDirCmp)) { return null } if (normalized.includes('/session-memory/') && normalized.endsWith('.md')) { return 'session_memory' } if (normalized.includes('/projects/') && normalized.endsWith('.jsonl')) { return 'session_transcript' } return null } /** * Checks if a glob/pattern string indicates session file access intent. * Used for Grep/Glob tools where we check patterns, not actual file paths. */ export function detectSessionPatternType( pattern: string, ): 'session_memory' | 'session_transcript' | null { const normalized = pattern.split(win32.sep).join(posix.sep) if ( normalized.includes('session-memory') && (normalized.includes('.md') || normalized.endsWith('*')) ) { return 'session_memory' } if ( normalized.includes('.jsonl') || (normalized.includes('projects') && normalized.includes('*.jsonl')) ) { return 'session_transcript' } return null } /** * Check if a file path is within the memdir directory. */ export function isAutoMemFile(filePath: string): boolean { if (isAutoMemoryEnabled()) { return isAutoMemPath(filePath) } return false } export type MemoryScope = 'personal' | 'team' /** * Determine which memory store (if any) a path belongs to. * * Team dir is a subdirectory of memdir (getTeamMemPath = join(getAutoMemPath, 'team')), * so a team path matches both isTeamMemFile and isAutoMemFile. Check team first. * * Use this for scope-keyed telemetry where a single event name distinguishes * by scope field — the existing tengu_memdir_* / tengu_team_mem_* event-name * hierarchy handles the overlap differently (team writes intentionally fire both). */ export function memoryScopeForPath(filePath: string): MemoryScope | null { if (feature('TEAMMEM') && teamMemPaths!.isTeamMemFile(filePath)) { return 'team' } if (isAutoMemFile(filePath)) { return 'personal' } return null } /** * Check if a file path is within an agent memory directory. */ function isAgentMemFile(filePath: string): boolean { if (isAutoMemoryEnabled()) { return isAgentMemoryPath(filePath) } return false } /** * Check if a file is a Claude-managed memory file (NOT user-managed instruction files). * Includes: auto-memory (memdir), agent memory, session memory/transcripts. * Excludes: CLAUDE.md, CLAUDE.local.md, .claude/rules/*.md (user-managed). * * Use this for collapse/badge logic where user-managed files should show full diffs. */ export function isAutoManagedMemoryFile(filePath: string): boolean { if (isAutoMemFile(filePath)) { return true } if (feature('TEAMMEM') && teamMemPaths!.isTeamMemFile(filePath)) { return true } if (detectSessionFileType(filePath) !== null) { return true } if (isAgentMemFile(filePath)) { return true } return false } // Check if a directory path is a memory-related directory. // Used by Grep/Glob which take a directory `path` rather than a specific file. // Checks both configDir and memoryBaseDir to handle custom memory dir paths. export function isMemoryDirectory(dirPath: string): boolean { // SECURITY: Normalize to prevent path traversal bypasses via .. segments. // On Windows this produces backslashes; toComparable flips them back for // string matching. MinGW /c/... paths are converted to native before // reaching here (extraction-time in isShellCommandTargetingMemory), so // normalize() never sees them. const normalizedPath = normalize(dirPath) const normalizedCmp = toComparable(normalizedPath) // Agent memory directories can be under cwd (project scope), configDir, or memoryBaseDir if ( isAutoMemoryEnabled() && (normalizedCmp.includes('/agent-memory/') || normalizedCmp.includes('/agent-memory-local/')) ) { return true } // Team memory directories live under /team/ if ( feature('TEAMMEM') && teamMemPaths!.isTeamMemoryEnabled() && teamMemPaths!.isTeamMemPath(normalizedPath) ) { return true } // Check the auto-memory path override (CLAUDE_COWORK_MEMORY_PATH_OVERRIDE) if (isAutoMemoryEnabled()) { const autoMemPath = getAutoMemPath() const autoMemDirCmp = toComparable(autoMemPath.replace(/[/\\]+$/, '')) const autoMemPathCmp = toComparable(autoMemPath) if ( normalizedCmp === autoMemDirCmp || normalizedCmp.startsWith(autoMemPathCmp) ) { return true } } const configDirCmp = toComparable(getClaudeConfigHomeDir()) const memoryBaseCmp = toComparable(getMemoryBaseDir()) const underConfig = normalizedCmp.startsWith(configDirCmp) const underMemoryBase = normalizedCmp.startsWith(memoryBaseCmp) if (!underConfig && !underMemoryBase) { return false } if (normalizedCmp.includes('/session-memory/')) { return true } if (underConfig && normalizedCmp.includes('/projects/')) { return true } if (isAutoMemoryEnabled() && normalizedCmp.includes('/memory/')) { return true } return false } /** * Check if a shell command string (Bash or PowerShell) targets memory files * by extracting absolute path tokens and checking them against memory * detection functions. Used for Bash/PowerShell grep/search commands in the * collapse logic. */ export function isShellCommandTargetingMemory(command: string): boolean { const configDir = getClaudeConfigHomeDir() const memoryBase = getMemoryBaseDir() const autoMemDir = isAutoMemoryEnabled() ? getAutoMemPath().replace(/[/\\]+$/, '') : '' // Quick check: does the command mention the config, memory base, or // auto-mem directory? Compare in forward-slash form (PowerShell on Windows // may use either separator while configDir uses the platform-native one). // On Windows also check the MinGW form (/c/...) since BashTool runs under // Git Bash which emits that encoding. On Linux/Mac, configDir is already // posix so only one form to check — and crucially, windowsPathToPosixPath // is NOT called, so Linux paths like /m/foo aren't misinterpreted as MinGW. const commandCmp = toComparable(command) const dirs = [configDir, memoryBase, autoMemDir].filter(Boolean) const matchesAnyDir = dirs.some(d => { if (commandCmp.includes(toComparable(d))) return true if (IS_WINDOWS) { // BashTool on Windows (Git Bash) emits /c/Users/... — check MinGW form too return commandCmp.includes(windowsPathToPosixPath(d).toLowerCase()) } return false }) if (!matchesAnyDir) { return false } // Extract absolute path-like tokens. Matches Unix absolute paths (/foo/bar), // Windows drive-letter paths (C:\foo, C:/foo), and MinGW paths (/c/foo — // they're /-prefixed so the regex already captures them). Bare backslash // tokens (\foo) are intentionally excluded — they appear in regex/grep // patterns and would cause false-positive memory classification after // normalization flips backslashes to forward slashes. const matches = command.match(/(?:[A-Za-z]:[/\\]|\/)[^\s'"]+/g) if (!matches) { return false } for (const match of matches) { // Strip trailing shell metacharacters that could be adjacent to a path const cleanPath = match.replace(/[,;|&>]+$/, '') // On Windows, convert MinGW /c/... → native C:\... at this single // point. Downstream predicates (isAutoManagedMemoryFile, isMemoryDirectory, // isAutoMemPath, isAgentMemoryPath) then receive native paths and only // need toComparable() for matching. On other platforms, paths are already // native — no conversion, so /m/foo etc. pass through unmodified. const nativePath = IS_WINDOWS ? posixPathToWindowsPath(cleanPath) : cleanPath if (isAutoManagedMemoryFile(nativePath) || isMemoryDirectory(nativePath)) { return true } } return false } // Check if a glob/pattern targets auto-managed memory files only. // Excludes CLAUDE.md, CLAUDE.local.md, .claude/rules/ (user-managed). // Used for collapse badge logic where user-managed files should not be // counted as "memory" operations. export function isAutoManagedMemoryPattern(pattern: string): boolean { if (detectSessionPatternType(pattern) !== null) { return true } if ( isAutoMemoryEnabled() && (pattern.replace(/\\/g, '/').includes('agent-memory/') || pattern.replace(/\\/g, '/').includes('agent-memory-local/')) ) { return true } return false }