656 lines · 20.4 KB · TypeScript
RawDownload
/** * CLAUDE.md Injection Tracker * * Tracks system context injections from various CLAUDE.md sources throughout a session. * Detects injections based on: * - Global sources (enterprise, user-memory, project-memory, project-rules, project-local) * - Directory-specific CLAUDE.md files (detected from Read tool calls and @ mentions) */ import { extractFileReferences } from './groupTransformer'; import type { ClaudeMdInjection, ClaudeMdSource, ClaudeMdStats } from '../types/claudeMd';import type { ClaudeMdFileInfo, ParsedMessage, SemanticStep } from '../types/data';import type { AIGroup, ChatItem, FileReference, UserGroup } from '../types/groups'; // =============================================================================// Constants// ============================================================================= /** Default estimated tokens for global CLAUDE.md sources */const DEFAULT_ESTIMATED_TOKENS = 500; /** CLAUDE.md filename to search for */const CLAUDE_MD_FILENAME = 'CLAUDE.md'; /** Source identifier for project memory CLAUDE.md files */const SOURCE_PROJECT_MEMORY: ClaudeMdSource = 'project-memory'; // =============================================================================// Helper Functions// ============================================================================= /** * Generate a unique ID for an injection based on its path. * Uses a simple hash-like approach for readability. */export function generateInjectionId(path: string): string {  // Create a simple hash from the path  let hash = 0;  for (let i = 0; i < path.length; i++) {    const char = path.charCodeAt(i);    hash = (hash << 5) - hash + char;    hash = hash & hash; // Convert to 32bit integer  }  // Convert to positive hex string  const positiveHash = Math.abs(hash).toString(16);  return `cmd-${positiveHash}`;} /** * Create a display name for a CLAUDE.md injection. * Returns the raw path for transparency. */export function getDisplayName(path: string, _source: ClaudeMdSource): string {  return path;} /** * Check if a path is absolute (starts with /). */function isAbsolutePath(path: string): boolean {  return path.startsWith('/') || path.startsWith('\\\\') || /^[a-zA-Z]:[\\/]/.test(path);} /** * Join paths, handling various path formats properly. * Handles: * - Absolute paths: /full/path/file.tsx (returned as-is) * - Relative paths with ./: ./apps/foo/bar.tsx (strips ./) * - Parent paths with ../: ../other/file.tsx (walks up directories) * - Plain paths: apps/foo/bar.tsx (joins with base) * - Paths with @ prefix: @apps/foo/bar.tsx (strips @ then joins) */function joinPaths(base: string, relative: string): string {  if (isAbsolutePath(relative)) {    return relative;  }   // Remove trailing slash from base if present  const cleanBase = trimTrailingSeparator(base);   // Handle @ prefix (file mention marker) - strip it if present  let cleanRelative = relative;  if (cleanRelative.startsWith('@')) {    cleanRelative = cleanRelative.slice(1);  }   // Handle ./ prefix (current directory)  if (cleanRelative.startsWith('./')) {    cleanRelative = cleanRelative.slice(2);  }   // Handle ../ prefixes (parent directory)  const separator = cleanBase.includes('\\') ? '\\' : '/';  const hasUnixRoot = cleanBase.startsWith('/');  const hasUncRoot = cleanBase.startsWith('\\\\');  const normalizedRelative = normalizeSeparators(cleanRelative, separator);  const baseParts = splitPath(cleanBase);  let remainingRelative = normalizedRelative;  while (remainingRelative.startsWith(`..${separator}`)) {    remainingRelative = remainingRelative.slice(3);    if (baseParts.length > 1) {      baseParts.pop();    }  }   // Join the normalized paths  let normalizedBase = baseParts.join(separator);  if (hasUnixRoot && !normalizedBase.startsWith('/')) {    normalizedBase = `/${normalizedBase}`;  }  if (hasUncRoot && !normalizedBase.startsWith('\\\\')) {    normalizedBase = `\\\\${normalizedBase}`;  }  return remainingRelative ? `${normalizedBase}${separator}${remainingRelative}` : normalizedBase;} function trimTrailingSeparator(input: string): string {  let end = input.length;  while (end > 0) {    const char = input[end - 1];    if (char !== '/' && char !== '\\') {      break;    }    end--;  }  return input.slice(0, end);} function normalizeSeparators(input: string, separator: '/' | '\\'): string {  let output = '';  let prevWasSeparator = false;   for (const char of input) {    const isSeparator = char === '/' || char === '\\';    if (isSeparator) {      if (!prevWasSeparator) {        output += separator;      }      prevWasSeparator = true;    } else {      output += char;      prevWasSeparator = false;    }  }   return output;} function splitPath(input: string): string[] {  const parts: string[] = [];  let current = '';   for (const char of input) {    if (char === '/' || char === '\\') {      if (current.length > 0) {        parts.push(current);        current = '';      }    } else {      current += char;    }  }   if (current.length > 0) {    parts.push(current);  }   return parts;} function normalizeForComparison(input: string): string {  return input.replace(/\\/g, '/');} /** * Get the directory containing a file. */export function getDirectory(filePath: string): string {  const lastSep = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));  if (lastSep === -1) return '';  return filePath.slice(0, lastSep);} /** * Get the parent directory of a path. */export function getParentDirectory(dirPath: string): string | null {  const lastSep = Math.max(dirPath.lastIndexOf('/'), dirPath.lastIndexOf('\\'));  if (lastSep <= 0) return null; // At root or invalid  return dirPath.slice(0, lastSep);} /** * Check if dirPath is at or above stopPath in the directory tree. */function isAtOrAbove(dirPath: string, stopPath: string): boolean {  const normDir = normalizeForComparison(dirPath).replace(/\/$/, '');  const normStop = normalizeForComparison(stopPath).replace(/\/$/, '');   // dirPath is at or above stopPath if stopPath starts with dirPath  return normStop === normDir || normStop.startsWith(normDir + '/');} // =============================================================================// Path Extraction Functions// ============================================================================= /** * Extract file paths from Read tool calls in semantic steps. */export function extractReadToolPaths(steps: SemanticStep[]): string[] {  const paths: string[] = [];   for (const step of steps) {    // Check if this is a Read tool call    if (step.type === 'tool_call' && step.content.toolName === 'Read') {      const toolInput = step.content.toolInput as Record<string, unknown> | undefined;      if (toolInput && typeof toolInput.file_path === 'string') {        paths.push(toolInput.file_path);      }    }  }   return paths;} /** * Extract file paths from user @ mentions. * Converts relative paths to absolute using projectRoot. */export function extractUserMentionPaths(  userGroup: UserGroup | null,  projectRoot: string): string[] {  if (!userGroup) return [];   const fileReferences = userGroup.content.fileReferences || [];  const paths: string[] = [];   for (const ref of fileReferences) {    if (ref.path) {      // Convert to absolute if relative      const absolutePath = isAbsolutePath(ref.path) ? ref.path : joinPaths(projectRoot, ref.path);      paths.push(absolutePath);    }  }   return paths;} /** * Extracts file references from isMeta:true user messages within AI group responses. * These are user-type messages generated by slash commands and other internal mechanisms * that contain @-mentioned file paths. */export function extractFileRefsFromResponses(responses: ParsedMessage[]): FileReference[] {  const refs: FileReference[] = [];  for (const msg of responses) {    if (msg.type !== 'user') continue;    let text = '';    if (typeof msg.content === 'string') {      text = msg.content;    } else if (Array.isArray(msg.content)) {      for (const block of msg.content) {        if (block.type === 'text' && block.text) text += block.text;      }    }    if (text) refs.push(...extractFileReferences(text));  }  return refs;} // =============================================================================// CLAUDE.md Detection Functions// ============================================================================= /** * Detect potential CLAUDE.md files by walking up from a file's directory to project root. * Returns paths to CLAUDE.md files that would be injected based on the file path. */export function detectClaudeMdFromFilePath(filePath: string, projectRoot: string): string[] {  const claudeMdPaths: string[] = [];  const sep = filePath.includes('\\') ? '\\' : '/';   // Get the directory containing the file  let currentDir = getDirectory(filePath);   // Walk up to project root (inclusive)  while (currentDir && isAtOrAbove(projectRoot, currentDir)) {    // Add potential CLAUDE.md path for this directory    const claudeMdPath = `${currentDir}${sep}${CLAUDE_MD_FILENAME}`;    claudeMdPaths.push(claudeMdPath);     // Move to parent directory    const parentDir = getParentDirectory(currentDir);    if (!parentDir || parentDir === currentDir) {      break;    }    currentDir = parentDir;  }   return claudeMdPaths;} // =============================================================================// Injection Creation Functions// ============================================================================= /** * Create injection entries for global CLAUDE.md sources. * These are injected at the start of every session. * Only includes files that actually exist (tokens > 0). */export function createGlobalInjections(  projectRoot: string,  aiGroupId: string,  tokenData?: Record<string, ClaudeMdFileInfo>): ClaudeMdInjection[] {  const injections: ClaudeMdInjection[] = [];   // Helper to get token count from tokenData or fallback to default  const getTokens = (key: string): number => {    return tokenData?.[key]?.estimatedTokens ?? DEFAULT_ESTIMATED_TOKENS;  };   // 1. Enterprise config  const enterprisePath =    tokenData?.enterprise?.path ?? '/Library/Application Support/ClaudeCode/CLAUDE.md';  const enterpriseTokens = getTokens('enterprise');  if (enterpriseTokens > 0) {    injections.push({      id: generateInjectionId(enterprisePath),      path: enterprisePath,      source: 'enterprise',      displayName: getDisplayName(enterprisePath, 'enterprise'),      isGlobal: true,      estimatedTokens: enterpriseTokens,      firstSeenInGroup: aiGroupId,    });  }   // 2. User memory (~/.claude/CLAUDE.md)  // Use ~ for display purposes (renderer cannot access Node.js process.env)  const userMemoryPath = '~/.claude/CLAUDE.md';  const userTokens = getTokens('user');  if (userTokens > 0) {    injections.push({      id: generateInjectionId(userMemoryPath),      path: userMemoryPath,      source: 'user-memory',      displayName: getDisplayName(userMemoryPath, 'user-memory'),      isGlobal: true,      estimatedTokens: userTokens,      firstSeenInGroup: aiGroupId,    });  }   // 3. Project memory - could be at root or in .claude folder  const projectMemoryPath = joinPaths(projectRoot, 'CLAUDE.md');  const projectMemoryAltPath = joinPaths(projectRoot, '.claude/CLAUDE.md');  // Add the main project CLAUDE.md  const projectTokens = getTokens('project');  if (projectTokens > 0) {    injections.push({      id: generateInjectionId(projectMemoryPath),      path: projectMemoryPath,      source: SOURCE_PROJECT_MEMORY,      displayName: getDisplayName(projectMemoryPath, SOURCE_PROJECT_MEMORY),      isGlobal: true,      estimatedTokens: projectTokens,      firstSeenInGroup: aiGroupId,    });  }  // Also add the .claude folder variant  const projectAltTokens = getTokens('project-alt');  if (projectAltTokens > 0) {    injections.push({      id: generateInjectionId(projectMemoryAltPath),      path: projectMemoryAltPath,      source: SOURCE_PROJECT_MEMORY,      displayName: getDisplayName(projectMemoryAltPath, SOURCE_PROJECT_MEMORY),      isGlobal: true,      estimatedTokens: projectAltTokens,      firstSeenInGroup: aiGroupId,    });  }   // 4. Project rules (*.md files in .claude/rules/)  const projectRulesPath = joinPaths(projectRoot, '.claude/rules/*.md');  const projectRulesTokens = getTokens('project-rules');  if (projectRulesTokens > 0) {    injections.push({      id: generateInjectionId(projectRulesPath),      path: projectRulesPath,      source: 'project-rules',      displayName: getDisplayName(projectRulesPath, 'project-rules'),      isGlobal: true,      estimatedTokens: projectRulesTokens,      firstSeenInGroup: aiGroupId,    });  }   // 5. Project local  const projectLocalPath = joinPaths(projectRoot, 'CLAUDE.local.md');  const projectLocalTokens = getTokens('project-local');  if (projectLocalTokens > 0) {    injections.push({      id: generateInjectionId(projectLocalPath),      path: projectLocalPath,      source: 'project-local',      displayName: getDisplayName(projectLocalPath, 'project-local'),      isGlobal: true,      estimatedTokens: projectLocalTokens,      firstSeenInGroup: aiGroupId,    });  }   // 6. User rules (~/.claude/rules/**/*.md)  const userRulesPath = '~/.claude/rules/**/*.md';  const userRulesTokens = getTokens('user-rules');  if (userRulesTokens > 0) {    injections.push({      id: generateInjectionId(userRulesPath),      path: userRulesPath,      source: 'user-rules',      displayName: getDisplayName(userRulesPath, 'user-rules'),      isGlobal: true,      estimatedTokens: userRulesTokens,      firstSeenInGroup: aiGroupId,    });  }   // 7. Auto memory (~/.claude/projects/<encoded>/memory/MEMORY.md)  const autoMemoryPath =    tokenData?.['auto-memory']?.path ?? '~/.claude/projects/.../memory/MEMORY.md';  const autoMemoryTokens = getTokens('auto-memory');  if (autoMemoryTokens > 0) {    injections.push({      id: generateInjectionId(autoMemoryPath),      path: autoMemoryPath,      source: 'auto-memory',      displayName: getDisplayName(autoMemoryPath, 'auto-memory'),      isGlobal: true,      estimatedTokens: autoMemoryTokens,      firstSeenInGroup: aiGroupId,    });  }   return injections;} /** * Create an injection entry for a directory-specific CLAUDE.md. */function createDirectoryInjection(path: string, aiGroupId: string): ClaudeMdInjection {  return {    id: generateInjectionId(path),    path,    source: 'directory',    displayName: getDisplayName(path, 'directory'),    isGlobal: false,    estimatedTokens: DEFAULT_ESTIMATED_TOKENS,    firstSeenInGroup: aiGroupId,  };} // =============================================================================// Stats Computation// ============================================================================= /** * Parameters for computing CLAUDE.md stats for an AI group. */interface ComputeClaudeMdStatsParams {  aiGroup: AIGroup;  userGroup: UserGroup | null;  isFirstGroup: boolean;  previousInjections: ClaudeMdInjection[];  projectRoot: string;  contextTokens: number;  tokenData?: Record<string, ClaudeMdFileInfo>;} /** * Compute CLAUDE.md injection statistics for an AI group. */function computeClaudeMdStats(params: ComputeClaudeMdStatsParams): ClaudeMdStats {  const {    aiGroup,    userGroup,    isFirstGroup,    previousInjections,    projectRoot,    contextTokens,    tokenData,  } = params;   const newInjections: ClaudeMdInjection[] = [];  const previousPaths = new Set(previousInjections.map((inj) => inj.path));

Lines 1–500 of 656.