agent-ecosystem/src/renderer/utils/claudeMdTracker.ts
iliya 52ef9fd0a8 feat: add FAQ section to README and improve cross-platform handling in code
- Introduced a comprehensive FAQ section in the README to address common user queries regarding app installation, code handling, agent communication, and project management.
- Enhanced cross-platform keyboard shortcut handling in the Electron app for better user experience on macOS and Windows/Linux.
- Updated signal handling in the standalone process to ensure proper shutdown behavior across platforms.
- Improved WSL user resolution logic to support default user retrieval for better compatibility.
- Enhanced notification handling to support cross-platform features and improve user feedback.
- Refactored SSH connection management to include additional key file types and improve authentication handling.
- Updated team management services to ensure consistent process termination across platforms.
- Improved project path handling in team provisioning to accommodate different operating systems.
- Enhanced editor components to utilize shared utility functions for path management, improving code maintainability.
2026-03-02 22:56:56 +02:00

644 lines
20 KiB
TypeScript

/**
* 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 {
lastSeparatorIndex,
splitPath as splitPathCrossPlatform,
} from '@shared/utils/platformPath';
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;
}
/** Local alias — delegates to the shared cross-platform splitPath. */
function splitPath(input: string): string[] {
return splitPathCrossPlatform(input);
}
function normalizeForComparison(input: string): string {
return input.replace(/\\/g, '/');
}
/**
* Get the directory containing a file.
*/
export function getDirectory(filePath: string): string {
const lastSep = lastSeparatorIndex(filePath);
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 = lastSeparatorIndex(dirPath);
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));
// For the first group, add global injections
// Use "ai-N" format for firstSeenInGroup to enable turn navigation in SessionClaudeMdPanel
const turnGroupId = `ai-${aiGroup.turnIndex}`;
if (isFirstGroup) {
const globalInjections = createGlobalInjections(projectRoot, turnGroupId, tokenData);
for (const injection of globalInjections) {
if (!previousPaths.has(injection.path)) {
newInjections.push(injection);
previousPaths.add(injection.path);
}
}
}
// Collect all file paths from Read tools and user @ mentions
const allFilePaths: string[] = [];
// Extract from Read tool calls in semantic steps
const readPaths = extractReadToolPaths(aiGroup.steps);
allFilePaths.push(...readPaths);
// Extract from user @ mentions
const mentionPaths = extractUserMentionPaths(userGroup, projectRoot);
allFilePaths.push(...mentionPaths);
// Extract from isMeta:true user messages in AI responses (slash command follow-ups)
const responseRefs = extractFileRefsFromResponses(aiGroup.responses);
for (const ref of responseRefs) {
if (ref.path) {
const absPath = isAbsolutePath(ref.path) ? ref.path : joinPaths(projectRoot, ref.path);
allFilePaths.push(absPath);
}
}
// For each file path, detect potential CLAUDE.md files
for (const filePath of allFilePaths) {
const claudeMdPaths = detectClaudeMdFromFilePath(filePath, projectRoot);
for (const claudeMdPath of claudeMdPaths) {
// Skip if already seen
if (previousPaths.has(claudeMdPath)) {
continue;
}
// Skip if this is a global path (already handled)
const isGlobalPath =
normalizeForComparison(claudeMdPath) ===
`${normalizeForComparison(projectRoot)}/CLAUDE.md` ||
normalizeForComparison(claudeMdPath) ===
`${normalizeForComparison(projectRoot)}/.claude/CLAUDE.md` ||
normalizeForComparison(claudeMdPath) ===
`${normalizeForComparison(projectRoot)}/CLAUDE.local.md`;
if (isGlobalPath) {
continue;
}
// Create directory injection
const injection = createDirectoryInjection(claudeMdPath, turnGroupId);
newInjections.push(injection);
previousPaths.add(claudeMdPath);
}
}
// Build accumulated injections
const accumulatedInjections = [...previousInjections, ...newInjections];
// Calculate totals
const totalEstimatedTokens = accumulatedInjections.reduce(
(sum, inj) => sum + inj.estimatedTokens,
0
);
// Calculate percentage of context
const percentageOfContext = contextTokens > 0 ? (totalEstimatedTokens / contextTokens) * 100 : 0;
return {
newInjections,
accumulatedInjections,
totalEstimatedTokens,
percentageOfContext,
newCount: newInjections.length,
accumulatedCount: accumulatedInjections.length,
};
}
// =============================================================================
// Session Processing
// =============================================================================
/**
* Process all chat items in a session and compute CLAUDE.md stats for each AI group.
* Returns a map of aiGroupId -> ClaudeMdStats.
*/
export function processSessionClaudeMd(
items: ChatItem[],
projectRoot: string,
tokenData?: Record<string, ClaudeMdFileInfo>
): Map<string, ClaudeMdStats> {
const statsMap = new Map<string, ClaudeMdStats>();
let accumulatedInjections: ClaudeMdInjection[] = [];
let isFirstAiGroup = true;
let previousUserGroup: UserGroup | null = null;
for (const item of items) {
// Track user groups for pairing with subsequent AI groups
if (item.type === 'user') {
previousUserGroup = item.group;
continue;
}
// Handle compact items: reset accumulated state across compaction boundaries
if (item.type === 'compact') {
accumulatedInjections = [];
isFirstAiGroup = true;
previousUserGroup = null;
continue;
}
// Process AI groups
if (item.type === 'ai') {
const aiGroup = item.group;
// Get context tokens from the AI group's metrics
// Use input tokens as a proxy for context window usage
const contextTokens = aiGroup.tokens.input || 0;
// Compute stats for this group
const stats = computeClaudeMdStats({
aiGroup,
userGroup: previousUserGroup,
isFirstGroup: isFirstAiGroup,
previousInjections: accumulatedInjections,
projectRoot,
contextTokens,
tokenData,
});
// Store stats
statsMap.set(aiGroup.id, stats);
// Update accumulated state for next iteration
accumulatedInjections = stats.accumulatedInjections;
isFirstAiGroup = false;
// Clear the user group pairing after processing
previousUserGroup = null;
}
}
return statsMap;
}
// =============================================================================
// Utility Exports
// =============================================================================