import * as plugins from '../plugins.js'; import { AiDoc } from '../classes.aidoc.js'; import { DiffProcessor } from '../classes.diffprocessor.js'; import { logger } from '../logging.js'; import { createReadOnlyFileSystemTools } from '../helpers.agenttools.js'; // Token budget configuration for OpenAI API limits const TOKEN_BUDGET = { OPENAI_CONTEXT_LIMIT: 272000, // OpenAI's configured limit SAFETY_MARGIN: 10000, // Buffer to avoid hitting exact limit SMARTAGENT_OVERHEAD: 180000, // System msgs, tools, history, formatting TASK_PROMPT_OVERHEAD: 2000, // Task prompt template size } as const; /** * Calculate max tokens available for diff content based on total budget */ function calculateMaxDiffTokens(): number { const available = TOKEN_BUDGET.OPENAI_CONTEXT_LIMIT - TOKEN_BUDGET.SAFETY_MARGIN - TOKEN_BUDGET.SMARTAGENT_OVERHEAD - TOKEN_BUDGET.TASK_PROMPT_OVERHEAD; return Math.max(available, 30000); } export interface INextCommitObject { recommendedNextVersionLevel: 'fix' | 'feat' | 'BREAKING CHANGE'; // the recommended next version level of the project recommendedNextVersionScope: string; // the recommended scope name of the next version, like "core" or "cli", or specific class names. recommendedNextVersionMessage: string; // the commit message. Don't put fix() feat() or BREAKING CHANGE in the message. Please just the message itself. recommendedNextVersionDetails: string[]; // detailed bullet points for the changelog recommendedNextVersion: string; // the recommended next version of the project, x.x.x changelog?: string; // the changelog for the next version } export class NoChangesError extends Error { constructor(message = 'No uncommitted changes found for commit recommendation.') { super(message); this.name = 'NoChangesError'; } } const normalizeLevel = (level: unknown): INextCommitObject['recommendedNextVersionLevel'] => { if (level === 'fix' || level === 'feat' || level === 'BREAKING CHANGE') return level; throw new Error('recommendedNextVersionLevel must be fix, feat, or BREAKING CHANGE.'); }; const stripConventionalPrefix = (message: string): string => { return message.replace(/^(fix|feat|BREAKING CHANGE)(\([^)]*\))?:\s*/i, '').trim(); }; const extractJsonObject = (text: string): Record => { const jsonString = text .replace(/```json\n?/gi, '') .replace(/```\n?/gi, '') .match(/\{[\s\S]*\}/)?.[0]; if (!jsonString) { throw new Error(`Could not find JSON object in result: ${text.substring(0, 100)}...`); } return JSON.parse(jsonString) as Record; }; const bumpVersion = (currentVersion: string, level: INextCommitObject['recommendedNextVersionLevel']): string => { const [majorString, minorString, patchString] = currentVersion.split(/[+-]/)[0].split('.'); let major = Number.parseInt(majorString, 10); let minor = Number.parseInt(minorString, 10); let patch = Number.parseInt(patchString, 10); if (!Number.isFinite(major)) major = 0; if (!Number.isFinite(minor)) minor = 0; if (!Number.isFinite(patch)) patch = 0; if (level === 'BREAKING CHANGE') { return `${major + 1}.0.0`; } if (level === 'feat') { return `${major}.${minor + 1}.0`; } return `${major}.${minor}.${patch + 1}`; }; const parseCommitResult = (text: string, currentVersion: string): INextCommitObject => { const parsed = extractJsonObject(text); const level = normalizeLevel(parsed.recommendedNextVersionLevel); const scope = typeof parsed.recommendedNextVersionScope === 'string' ? parsed.recommendedNextVersionScope.trim() : ''; const message = typeof parsed.recommendedNextVersionMessage === 'string' ? stripConventionalPrefix(parsed.recommendedNextVersionMessage) : ''; const details = Array.isArray(parsed.recommendedNextVersionDetails) ? parsed.recommendedNextVersionDetails.filter((detail): detail is string => typeof detail === 'string').map(detail => detail.trim()).filter(Boolean) : []; if (!scope) throw new Error('recommendedNextVersionScope must be a non-empty string.'); if (!message) throw new Error('recommendedNextVersionMessage must be a non-empty string.'); return { recommendedNextVersionLevel: level, recommendedNextVersionScope: scope, recommendedNextVersionMessage: message, recommendedNextVersionDetails: details, recommendedNextVersion: bumpVersion(currentVersion, level), }; }; const buildChangelog = ( commitObject: INextCommitObject, oldChangelog: string, ): string => { const dateString = new plugins.smarttime.ExtendedDate().exportToHyphedSortableDate(); const details = commitObject.recommendedNextVersionDetails.length > 0 ? `\n\n${commitObject.recommendedNextVersionDetails.map(detail => `- ${detail}`).join('\n')}` : ''; const previous = oldChangelog.replace(/^# Changelog\n\n?/, '').replace(/^\n+/, ''); return `# Changelog\n\n## ${dateString} - ${commitObject.recommendedNextVersion} - ${commitObject.recommendedNextVersionScope}\n${commitObject.recommendedNextVersionMessage}${details}\n\n${previous}`.trimEnd() + '\n'; }; export class Commit { private aiDocsRef: AiDoc; private projectDir: string; constructor(aiDocsRef: AiDoc, projectDirArg: string) { this.aiDocsRef = aiDocsRef; this.projectDir = projectDirArg; } public async buildNextCommitObject(): Promise { const smartgitInstance = new plugins.smartgit.Smartgit(); await smartgitInstance.init(); const gitRepo = await plugins.smartgit.GitRepo.fromOpeningRepoDir( smartgitInstance, this.projectDir ); // Define comprehensive exclusion patterns // smartgit@3.3.0+ supports glob patterns natively const excludePatterns = [ // Lock files 'pnpm-lock.yaml', 'package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock', 'deno.lock', 'bun.lockb', // Build artifacts (main culprit for large diffs!) 'dist/**', 'dist_*/**', // dist_ts, dist_web, etc. 'build/**', '.next/**', 'out/**', 'public/dist/**', // Compiled/bundled files '**/*.js.map', '**/*.d.ts.map', '**/*.min.js', '**/*.bundle.js', '**/*.chunk.js', // IDE/Editor directories '.claude/**', '.cursor/**', '.vscode/**', '.idea/**', '**/*.swp', '**/*.swo', // Logs and caches '.nogit/**', '**/*.log', '.cache/**', '.rpt2_cache/**', 'coverage/**', '.nyc_output/**', ]; // Pass glob patterns directly to smartgit - it handles matching internally const diffStringArray = await gitRepo.getUncommittedDiff(excludePatterns); if (diffStringArray.length === 0) { throw new NoChangesError(); } const packageJsonPath = plugins.path.join(this.projectDir, 'package.json'); const packageJson = await plugins.fsInstance.file(packageJsonPath).exists() ? JSON.parse(String(await plugins.fsInstance.file(packageJsonPath).encoding('utf8').read())) : {}; const currentVersion = typeof packageJson.version === 'string' ? packageJson.version : '0.0.0'; // Process diffs intelligently using DiffProcessor const totalChars = diffStringArray.join('\n\n').length; const estimatedTokens = Math.ceil(totalChars / 4); logger.log('info', `Raw git diff: ${diffStringArray.length} files, ${estimatedTokens.toLocaleString()} estimated tokens, ${excludePatterns.length} exclusions.`); const maxDiffTokens = calculateMaxDiffTokens(); const diffProcessor = new DiffProcessor({ maxDiffTokens, smallFileLines: 300, mediumFileLines: 800, sampleHeadLines: 75, sampleTailLines: 75, }); const processedDiff = diffProcessor.processDiffs(diffStringArray); const processedDiffString = diffProcessor.formatForContext(processedDiff); logger.log('info', `Processed diff: ${processedDiff.fullDiffs.length} full, ${processedDiff.summarizedDiffs.length} summarized, ${processedDiff.metadataOnly.length} metadata-only, ${processedDiff.totalTokens.toLocaleString()} tokens.`); const totalEstimatedTokens = processedDiff.totalTokens + TOKEN_BUDGET.SMARTAGENT_OVERHEAD + TOKEN_BUDGET.TASK_PROMPT_OVERHEAD; if (totalEstimatedTokens > TOKEN_BUDGET.OPENAI_CONTEXT_LIMIT - TOKEN_BUDGET.SAFETY_MARGIN) { logger.log('warn', `Estimated tokens (${totalEstimatedTokens.toLocaleString()}) approach the model context limit. Consider splitting into smaller commits.`); } const fsTools = await createReadOnlyFileSystemTools(this.projectDir); const commitSystemPrompt = ` You create commit messages for git commits following semantic versioning conventions. You have access to filesystem tools to explore the project if needed. IMPORTANT RULES: - Only READ files (package.json, source files) for context - Do NOT write, delete, or modify any files - Version level (fix/feat/BREAKING CHANGE) must match the scope of changes - Commit message must be clear, professional, and follow conventional commit conventions - Do NOT include personal information, licensing details, or AI mentions (Claude/Codex) - JSON structure must be valid with all required fields - Scope must accurately reflect the changed modules/files `; const commitTaskPrompt = ` Project directory: ${this.projectDir} You have access to filesystem tools to explore the project if needed: - Use list_directory to see project structure - Use read_file to read package.json or source files for context Analyze the git diff below to understand what changed and generate a commit message. You should not include any licensing information or personal information. Never mention CLAUDE code, or codex. Your final response must be ONLY valid JSON - the raw JSON object, nothing else. No explanations, no summaries, no markdown - just the JSON object that can be parsed with JSON.parse(). Here is the structure of the JSON you must return: { "recommendedNextVersionLevel": "fix" | "feat" | "BREAKING CHANGE", "recommendedNextVersionScope": "string", "recommendedNextVersionMessage": "string (ONLY the description body WITHOUT the type(scope): prefix - e.g. 'bump dependency to ^1.2.6' NOT 'fix(deps): bump dependency to ^1.2.6')", "recommendedNextVersionDetails": ["string"], "recommendedNextVersion": "x.x.x (will be verified deterministically)" } For recommendedNextVersionDetails, only add entries that have obvious value to the reader. Here is the git diff showing what changed: ${processedDiffString} Analyze these changes and output the JSON commit message object. `; logger.log('info', 'Starting commit message generation with agent...'); const commitResult = await this.aiDocsRef.runAgent({ taskName: 'commit', projectDir: this.projectDir, prompt: commitTaskPrompt, system: commitSystemPrompt, tools: fsTools, maxSteps: 10, maxValidationRetries: 1, validateCompletion: (result) => { try { parseCommitResult(result.text, currentVersion); } catch (error) { return `Return only valid JSON matching the requested commit object schema. Error: ${error instanceof Error ? error.message : String(error)}`; } }, onToolCall: (toolName) => logger.log('info', `[Commit] Tool call: ${toolName}`), }); const resultObject = parseCommitResult(commitResult.text, currentVersion); const previousChangelogPath = plugins.path.join(this.projectDir, 'changelog.md'); let oldChangelog = ''; if (await plugins.fsInstance.file(previousChangelogPath).exists()) { oldChangelog = String(await plugins.fsInstance.file(previousChangelogPath).encoding('utf8').read()); } resultObject.changelog = buildChangelog(resultObject, oldChangelog); return resultObject; } }