diff --git a/packages/mcp-server/bin/dev-agent-mcp.ts b/packages/mcp-server/bin/dev-agent-mcp.ts index eb42fca..97ba037 100644 --- a/packages/mcp-server/bin/dev-agent-mcp.ts +++ b/packages/mcp-server/bin/dev-agent-mcp.ts @@ -156,8 +156,21 @@ async function main() { defaultSection: 'summary', }); + // Create git extractor and indexer (needed by plan and history adapters) + const gitExtractor = new LocalGitExtractor(repositoryPath); + const gitVectorStorage = new VectorStorage({ + storePath: `${filePaths.vectors}-git`, + }); + await gitVectorStorage.initialize(); + + const gitIndexer = new GitIndexer({ + extractor: gitExtractor, + vectorStorage: gitVectorStorage, + }); + const planAdapter = new PlanAdapter({ repositoryIndexer: indexer, + gitIndexer, repositoryPath, defaultFormat: 'compact', timeout: 60000, // 60 seconds @@ -198,19 +211,6 @@ async function main() { defaultTokenBudget: 2000, }); - // Create git extractor and indexer for history adapter - // Note: GitIndexer uses the same vector storage for commit embeddings - const gitExtractor = new LocalGitExtractor(repositoryPath); - const gitVectorStorage = new VectorStorage({ - storePath: `${filePaths.vectors}-git`, - }); - await gitVectorStorage.initialize(); - - const gitIndexer = new GitIndexer({ - extractor: gitExtractor, - vectorStorage: gitVectorStorage, - }); - const historyAdapter = new HistoryAdapter({ gitIndexer, gitExtractor, diff --git a/packages/mcp-server/src/adapters/__tests__/plan-adapter.test.ts b/packages/mcp-server/src/adapters/__tests__/plan-adapter.test.ts index 1ce15e3..daf98b5 100644 --- a/packages/mcp-server/src/adapters/__tests__/plan-adapter.test.ts +++ b/packages/mcp-server/src/adapters/__tests__/plan-adapter.test.ts @@ -88,11 +88,13 @@ describe('PlanAdapter', () => { testLocation: '__tests__/', }, relatedHistory: [], + relatedCommits: [], metadata: { generatedAt: '2024-01-01T00:00:00Z', tokensUsed: 500, codeSearchUsed: true, historySearchUsed: false, + gitHistorySearchUsed: false, repositoryPath: '/test/repo', }, }); @@ -105,7 +107,7 @@ describe('PlanAdapter', () => { describe('metadata', () => { it('should have correct metadata', () => { expect(adapter.metadata.name).toBe('plan-adapter'); - expect(adapter.metadata.version).toBe('2.0.0'); + expect(adapter.metadata.version).toBe('2.1.0'); expect(adapter.metadata.description).toContain('context'); }); }); @@ -265,7 +267,7 @@ describe('PlanAdapter', () => { expect(utils.assembleContext).toHaveBeenCalledWith( 29, - mockIndexer, + expect.objectContaining({ indexer: mockIndexer }), '/test/repo', expect.objectContaining({ includeCode: false, diff --git a/packages/mcp-server/src/adapters/built-in/plan-adapter.ts b/packages/mcp-server/src/adapters/built-in/plan-adapter.ts index 7a21c23..9be0364 100644 --- a/packages/mcp-server/src/adapters/built-in/plan-adapter.ts +++ b/packages/mcp-server/src/adapters/built-in/plan-adapter.ts @@ -5,7 +5,7 @@ * Philosophy: Provide raw, structured context - let the LLM do the reasoning */ -import type { RepositoryIndexer } from '@lytics/dev-agent-core'; +import type { GitIndexer, RepositoryIndexer } from '@lytics/dev-agent-core'; import type { ContextAssemblyOptions } from '@lytics/dev-agent-subagents'; import { assembleContext, formatContextPackage } from '@lytics/dev-agent-subagents'; import { estimateTokensForText, startTimer } from '../../formatters/utils'; @@ -21,6 +21,11 @@ export interface PlanAdapterConfig { */ repositoryIndexer: RepositoryIndexer; + /** + * Git indexer instance (for finding relevant commits) + */ + gitIndexer?: GitIndexer; + /** * Repository path */ @@ -44,12 +49,13 @@ export interface PlanAdapterConfig { export class PlanAdapter extends ToolAdapter { readonly metadata = { name: 'plan-adapter', - version: '2.0.0', - description: 'GitHub issue context assembler', + version: '2.1.0', + description: 'GitHub issue context assembler with git history', author: 'Dev-Agent Team', }; private indexer: RepositoryIndexer; + private gitIndexer?: GitIndexer; private repositoryPath: string; private defaultFormat: 'compact' | 'verbose'; private timeout: number; @@ -57,6 +63,7 @@ export class PlanAdapter extends ToolAdapter { constructor(config: PlanAdapterConfig) { super(); this.indexer = config.repositoryIndexer; + this.gitIndexer = config.gitIndexer; this.repositoryPath = config.repositoryPath; this.defaultFormat = config.defaultFormat ?? 'compact'; this.timeout = config.timeout ?? 60000; // 60 seconds default @@ -105,6 +112,11 @@ export class PlanAdapter extends ToolAdapter { description: 'Maximum tokens for output (default: 4000)', default: 4000, }, + includeGitHistory: { + type: 'boolean', + description: 'Include related git commits (default: true)', + default: true, + }, }, required: ['issue'], }, @@ -118,6 +130,7 @@ export class PlanAdapter extends ToolAdapter { includeCode = true, includePatterns = true, tokenBudget = 4000, + includeGitHistory = true, } = args; // Validate issue number @@ -150,6 +163,7 @@ export class PlanAdapter extends ToolAdapter { format, includeCode, includePatterns, + includeGitHistory, tokenBudget, }); @@ -157,12 +171,19 @@ export class PlanAdapter extends ToolAdapter { includeCode: includeCode as boolean, includePatterns: includePatterns as boolean, includeHistory: false, // TODO: Enable when GitHub indexer integration is ready + includeGitHistory: (includeGitHistory as boolean) && !!this.gitIndexer, maxCodeResults: 10, + maxGitCommitResults: 5, tokenBudget: tokenBudget as number, }; const contextPackage = await this.withTimeout( - assembleContext(issue as number, this.indexer, this.repositoryPath, options), + assembleContext( + issue as number, + { indexer: this.indexer, gitIndexer: this.gitIndexer }, + this.repositoryPath, + options + ), this.timeout ); @@ -178,6 +199,7 @@ export class PlanAdapter extends ToolAdapter { context.logger.info('Context assembled', { issue, codeResults: contextPackage.relevantCode.length, + commitResults: contextPackage.relatedCommits.length, hasPatterns: !!contextPackage.codebasePatterns.testPattern, tokens, duration_ms, diff --git a/packages/subagents/src/planner/context-types.ts b/packages/subagents/src/planner/context-types.ts index f821a97..b5a88c2 100644 --- a/packages/subagents/src/planner/context-types.ts +++ b/packages/subagents/src/planner/context-types.ts @@ -91,6 +91,26 @@ export interface RelatedHistory { summary?: string; } +/** + * Related git commit + */ +export interface RelatedCommit { + /** Commit hash (short) */ + hash: string; + /** Commit subject line */ + subject: string; + /** Author name */ + author: string; + /** Commit date (ISO) */ + date: string; + /** Files changed */ + filesChanged: string[]; + /** Issue/PR references found in commit */ + issueRefs: number[]; + /** Relevance score (0-1) */ + relevanceScore: number; +} + /** * Complete context package for LLM consumption */ @@ -103,6 +123,8 @@ export interface ContextPackage { codebasePatterns: CodebasePatterns; /** Related closed issues/PRs */ relatedHistory: RelatedHistory[]; + /** Related git commits (from semantic search) */ + relatedCommits: RelatedCommit[]; /** Metadata about the context assembly */ metadata: ContextMetadata; } @@ -119,6 +141,8 @@ export interface ContextMetadata { codeSearchUsed: boolean; /** Whether history search was used */ historySearchUsed: boolean; + /** Whether git history was searched */ + gitHistorySearchUsed: boolean; /** Repository path */ repositoryPath: string; } @@ -133,10 +157,14 @@ export interface ContextAssemblyOptions { includeHistory?: boolean; /** Include codebase patterns (default: true) */ includePatterns?: boolean; + /** Include git commit history (default: true) */ + includeGitHistory?: boolean; /** Maximum code results (default: 10) */ maxCodeResults?: number; /** Maximum history results (default: 5) */ maxHistoryResults?: number; + /** Maximum git commit results (default: 5) */ + maxGitCommitResults?: number; /** Token budget for output (default: 4000) */ tokenBudget?: number; } diff --git a/packages/subagents/src/planner/index.ts b/packages/subagents/src/planner/index.ts index f3ce71d..4c3c4bf 100644 --- a/packages/subagents/src/planner/index.ts +++ b/packages/subagents/src/planner/index.ts @@ -215,3 +215,6 @@ export class PlannerAgent implements Agent { export type * from './context-types'; // Export types export type * from './types'; +export type { ContextAssemblyContext } from './utils/context-assembler'; +// Export context assembler utilities +export { assembleContext, formatContextPackage } from './utils/context-assembler'; diff --git a/packages/subagents/src/planner/utils/__tests__/context-assembler.test.ts b/packages/subagents/src/planner/utils/__tests__/context-assembler.test.ts index 8007cc6..b153b8a 100644 --- a/packages/subagents/src/planner/utils/__tests__/context-assembler.test.ts +++ b/packages/subagents/src/planner/utils/__tests__/context-assembler.test.ts @@ -225,11 +225,13 @@ describe('Context Assembler', () => { relevanceScore: 0.7, }, ], + relatedCommits: [], metadata: { generatedAt: '2025-01-03T00:00:00Z', tokensUsed: 500, codeSearchUsed: true, historySearchUsed: true, + gitHistorySearchUsed: false, repositoryPath: '/repo', }, }; @@ -364,5 +366,126 @@ describe('Context Assembler', () => { expect(output).toContain('**Issue #5:** Related bug (closed)'); }); + + it('should format related commits', () => { + const contextWithCommits: ContextPackage = { + ...mockContext, + relatedCommits: [ + { + hash: 'abc123', + subject: 'feat: add authentication', + author: 'dev', + date: '2025-01-15T10:00:00Z', + filesChanged: ['src/auth.ts', 'src/types.ts'], + issueRefs: [42], + relevanceScore: 0.9, + }, + ], + }; + + const output = formatContextPackage(contextWithCommits); + + expect(output).toContain('## Related Commits'); + expect(output).toContain('`abc123`'); + expect(output).toContain('feat: add authentication'); + expect(output).toContain('dev'); + expect(output).toContain('#42'); + expect(output).toContain('src/auth.ts'); + }); + + it('should truncate long file lists in commits', () => { + const contextWithManyFiles: ContextPackage = { + ...mockContext, + relatedCommits: [ + { + hash: 'def456', + subject: 'refactor: big change', + author: 'dev', + date: '2025-01-15T10:00:00Z', + filesChanged: ['a.ts', 'b.ts', 'c.ts', 'd.ts', 'e.ts'], + issueRefs: [], + relevanceScore: 0.8, + }, + ], + }; + + const output = formatContextPackage(contextWithManyFiles); + + expect(output).toContain('+2 more'); + }); + }); + + describe('Git History Integration', () => { + const mockGitIndexer = { + search: vi.fn().mockResolvedValue([ + { + shortHash: 'abc123', + subject: 'feat: add JWT auth', + author: { name: 'developer', date: new Date('2025-01-15') }, + files: [{ path: 'src/auth.ts' }], + refs: { issueRefs: [42] }, + }, + { + shortHash: 'def456', + subject: 'fix: token validation', + author: { name: 'developer', date: new Date('2025-01-14') }, + files: [{ path: 'src/auth.ts' }, { path: 'src/utils.ts' }], + refs: { issueRefs: [] }, + }, + ]), + }; + + it('should include related commits when git indexer is provided', async () => { + const result = await assembleContext( + 42, + { indexer: mockIndexer, gitIndexer: mockGitIndexer as any }, + '/repo', + { includeGitHistory: true } + ); + + expect(result.relatedCommits).toHaveLength(2); + expect(result.relatedCommits[0].hash).toBe('abc123'); + expect(result.relatedCommits[0].subject).toBe('feat: add JWT auth'); + expect(result.metadata.gitHistorySearchUsed).toBe(true); + }); + + it('should skip git history when includeGitHistory is false', async () => { + const result = await assembleContext( + 42, + { indexer: mockIndexer, gitIndexer: mockGitIndexer as any }, + '/repo', + { includeGitHistory: false } + ); + + expect(result.relatedCommits).toHaveLength(0); + expect(mockGitIndexer.search).not.toHaveBeenCalled(); + }); + + it('should skip git history when git indexer is null', async () => { + const result = await assembleContext( + 42, + { indexer: mockIndexer, gitIndexer: null }, + '/repo', + { includeGitHistory: true } + ); + + expect(result.relatedCommits).toHaveLength(0); + expect(result.metadata.gitHistorySearchUsed).toBe(false); + }); + + it('should handle git search errors gracefully', async () => { + const errorGitIndexer = { + search: vi.fn().mockRejectedValue(new Error('Git search failed')), + }; + + const result = await assembleContext( + 42, + { indexer: mockIndexer, gitIndexer: errorGitIndexer as any }, + '/repo', + { includeGitHistory: true } + ); + + expect(result.relatedCommits).toHaveLength(0); + }); }); }); diff --git a/packages/subagents/src/planner/utils/context-assembler.ts b/packages/subagents/src/planner/utils/context-assembler.ts index c4cfe73..fa90aa1 100644 --- a/packages/subagents/src/planner/utils/context-assembler.ts +++ b/packages/subagents/src/planner/utils/context-assembler.ts @@ -5,13 +5,14 @@ * Philosophy: Provide raw, structured context - let the LLM do the reasoning */ -import type { RepositoryIndexer } from '@lytics/dev-agent-core'; +import type { GitIndexer, RepositoryIndexer } from '@lytics/dev-agent-core'; import type { CodebasePatterns, ContextAssemblyOptions, ContextMetadata, ContextPackage, IssueContext, + RelatedCommit, RelatedHistory, RelevantCodeContext, } from '../context-types'; @@ -23,11 +24,21 @@ const DEFAULT_OPTIONS: Required = { includeCode: true, includeHistory: true, includePatterns: true, + includeGitHistory: true, maxCodeResults: 10, maxHistoryResults: 5, + maxGitCommitResults: 5, tokenBudget: 4000, }; +/** + * Context for assembly including optional git indexer + */ +export interface ContextAssemblyContext { + indexer: RepositoryIndexer | null; + gitIndexer?: GitIndexer | null; +} + /** * Assemble a context package for a GitHub issue * @@ -41,24 +52,53 @@ export async function assembleContext( issueNumber: number, indexer: RepositoryIndexer | null, repositoryPath: string, + options?: ContextAssemblyOptions +): Promise; + +/** + * Assemble a context package with git history support + * + * @param issueNumber - GitHub issue number + * @param context - Context with indexer and optional git indexer + * @param repositoryPath - Path to repository + * @param options - Assembly options + * @returns Complete context package + */ +export async function assembleContext( + issueNumber: number, + context: ContextAssemblyContext, + repositoryPath: string, + options?: ContextAssemblyOptions +): Promise; + +export async function assembleContext( + issueNumber: number, + indexerOrContext: RepositoryIndexer | null | ContextAssemblyContext, + repositoryPath: string, options: ContextAssemblyOptions = {} ): Promise { const opts = { ...DEFAULT_OPTIONS, ...options }; + // Normalize input + const context: ContextAssemblyContext = + indexerOrContext && 'indexer' in indexerOrContext + ? indexerOrContext + : { indexer: indexerOrContext as RepositoryIndexer | null }; + // 1. Fetch issue with comments const issue = await fetchGitHubIssue(issueNumber, repositoryPath, { includeComments: true }); const issueContext = convertToIssueContext(issue); // 2. Search for relevant code let relevantCode: RelevantCodeContext[] = []; - if (opts.includeCode && indexer) { - relevantCode = await findRelevantCode(issue, indexer, opts.maxCodeResults); + if (opts.includeCode && context.indexer) { + relevantCode = await findRelevantCode(issue, context.indexer, opts.maxCodeResults); } // 3. Detect codebase patterns let codebasePatterns: CodebasePatterns = {}; - if (opts.includePatterns && indexer) { - codebasePatterns = await detectCodebasePatterns(indexer); + if (opts.includePatterns && context.indexer) { + codebasePatterns = await detectCodebasePatterns(context.indexer); } // 4. Find related history (TODO: implement when GitHub indexer is available) @@ -67,15 +107,28 @@ export async function assembleContext( // relatedHistory = await findRelatedHistory(issue, githubIndexer, opts.maxHistoryResults); // } - // 5. Calculate approximate token count - const tokensUsed = estimateTokens(issueContext, relevantCode, codebasePatterns, relatedHistory); + // 5. Find related git commits + let relatedCommits: RelatedCommit[] = []; + if (opts.includeGitHistory && context.gitIndexer) { + relatedCommits = await findRelatedCommits(issue, context.gitIndexer, opts.maxGitCommitResults); + } + + // 6. Calculate approximate token count + const tokensUsed = estimateTokens( + issueContext, + relevantCode, + codebasePatterns, + relatedHistory, + relatedCommits + ); - // 6. Assemble metadata + // 7. Assemble metadata const metadata: ContextMetadata = { generatedAt: new Date().toISOString(), tokensUsed, - codeSearchUsed: opts.includeCode && indexer !== null, + codeSearchUsed: opts.includeCode && context.indexer !== null, historySearchUsed: opts.includeHistory && relatedHistory.length > 0, + gitHistorySearchUsed: opts.includeGitHistory && relatedCommits.length > 0, repositoryPath, }; @@ -84,6 +137,7 @@ export async function assembleContext( relevantCode, codebasePatterns, relatedHistory, + relatedCommits, metadata, }; } @@ -188,6 +242,36 @@ function inferRelevanceReason(metadata: Record, issue: GitHubIs return `Semantic similarity`; } +/** + * Find related git commits using semantic search + */ +async function findRelatedCommits( + issue: GitHubIssue, + gitIndexer: GitIndexer, + maxResults: number +): Promise { + // Build search query from issue title and body + const searchQuery = buildSearchQuery(issue); + + try { + const commits = await gitIndexer.search(searchQuery, { limit: maxResults }); + + return commits.map((commit, index) => ({ + hash: commit.shortHash, + subject: commit.subject, + author: commit.author.name, + date: commit.author.date, // Already an ISO string + filesChanged: commit.files.map((f) => f.path), + issueRefs: commit.refs.issueRefs, + // Decay relevance score by position + relevanceScore: Math.max(0.5, 1 - index * 0.1), + })); + } catch { + // Return empty array if search fails + return []; + } +} + /** * Detect codebase patterns from indexed data */ @@ -233,7 +317,8 @@ function estimateTokens( issue: IssueContext, code: RelevantCodeContext[], patterns: CodebasePatterns, - history: RelatedHistory[] + history: RelatedHistory[], + commits: RelatedCommit[] ): number { // Rough estimation: ~4 chars per token let chars = 0; @@ -255,6 +340,12 @@ function estimateTokens( // History chars += history.reduce((sum, h) => sum + h.title.length + (h.summary?.length || 0), 0); + // Git commits + chars += commits.reduce( + (sum, c) => sum + c.subject.length + c.author.length + c.filesChanged.join('').length, + 0 + ); + return Math.ceil(chars / 4); } @@ -331,6 +422,31 @@ export function formatContextPackage(context: ContextPackage): string { lines.push(''); } + // Related commits + if (context.relatedCommits.length > 0) { + lines.push('## Related Commits'); + lines.push(''); + for (const commit of context.relatedCommits) { + const issueLinks = + commit.issueRefs.length > 0 + ? ` (refs: ${commit.issueRefs.map((n) => `#${n}`).join(', ')})` + : ''; + lines.push(`- **\`${commit.hash}\`** ${commit.subject}${issueLinks}`); + lines.push(` - *${commit.author}* on ${commit.date.split('T')[0]}`); + if (commit.filesChanged.length > 0) { + const files = + commit.filesChanged.length <= 3 + ? commit.filesChanged.map((f) => `\`${f}\``).join(', ') + : `${commit.filesChanged + .slice(0, 3) + .map((f) => `\`${f}\``) + .join(', ')} +${commit.filesChanged.length - 3} more`; + lines.push(` - Files: ${files}`); + } + } + lines.push(''); + } + // Metadata lines.push('---'); lines.push(