Skip to content

Commit 563f1b7

Browse files
committed
feat(mcp): add prompts, token footers, and fix GitHub indexing
## Features ### 1. Prompts System (MCP Protocol) - Implement prompts/list and prompts/get handlers - Add 8 guided workflow prompts: - analyze-issue: Full issue analysis with implementation plan - find-pattern: Search codebase for patterns - repo-overview: Comprehensive repository health dashboard - find-similar: Find code similar to a file - search-github: Search issues/PRs by topic - explore-relationships: Analyze file dependencies - create-plan: Generate detailed task breakdown - quick-search: Fast semantic code search - Enable prompts capability in server initialization - Create PromptRegistry with argument validation ### 2. Token Cost Visibility - Add 🪙 coin emoji footers to all tool outputs - Display accurate token estimates at end of responses - Helps users make informed decisions about format choices - Shows ~36 tokens (compact) vs ~462 tokens (verbose) examples ### 3. Calibrated Token Estimation - Improve accuracy from 12.9% error to 0.6% error - Change formula: 4 chars/token → 4.5 chars/token - Adjust word estimate: 1.3 → 1.25 tokens/word - Validated against actual Cursor usage (178 tokens) - Add comprehensive accuracy tests ## Bug Fixes ### GitHub Indexing Status - Fix path mismatch: use consistent vectorStorePath-github pattern - Load repository name from state file to avoid gh CLI calls - Enable offline operation with cached GitHub data - Fix GitHubAdapter and StatusAdapter initialization - Now correctly shows: ✅ GitHub (40 items) instead of ⚠️ not indexed ### CLI Stats Command - Add GitHub integration section to 'dev stats' output - Display issues, PRs, and sync status - Load GitHubIndexer with repository from state file - Show comprehensive repository and GitHub metrics ## Tests - Update all formatter tests for token footers - Add token footer validation tests - Fix status adapter test expectations (debug → warn) - Add calibrated token estimation accuracy tests - All 246 tests passing ## Documentation - Update token estimation comments with calibration details - Add prompt definitions with descriptions and arguments - Document offline GitHub operation capability Closes #26 (MCP Integration improvements)
1 parent 6ae4588 commit 563f1b7

File tree

13 files changed

+711
-37
lines changed

13 files changed

+711
-37
lines changed

packages/cli/src/commands/stats.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import * as fs from 'node:fs/promises';
2+
import * as path from 'node:path';
13
import { RepositoryIndexer } from '@lytics/dev-agent-core';
4+
import { GitHubIndexer } from '@lytics/dev-agent-subagents';
25
import chalk from 'chalk';
36
import { Command } from 'commander';
47
import ora from 'ora';
@@ -25,6 +28,36 @@ export const statsCommand = new Command('stats')
2528
await indexer.initialize();
2629

2730
const stats = await indexer.getStats();
31+
32+
// Try to load GitHub stats
33+
let githubStats = null;
34+
try {
35+
// Try to load repository from state file
36+
let repository: string | undefined;
37+
const statePath = path.join(config.repositoryPath, '.dev-agent/github-state.json');
38+
try {
39+
const stateContent = await fs.readFile(statePath, 'utf-8');
40+
const state = JSON.parse(stateContent);
41+
repository = state.repository;
42+
} catch {
43+
// State file doesn't exist
44+
}
45+
46+
const githubIndexer = new GitHubIndexer(
47+
{
48+
vectorStorePath: `${config.vectorStorePath}-github`,
49+
statePath,
50+
autoUpdate: false,
51+
},
52+
repository
53+
);
54+
await githubIndexer.initialize();
55+
githubStats = githubIndexer.getStats();
56+
await githubIndexer.close();
57+
} catch {
58+
// GitHub not indexed, ignore
59+
}
60+
2861
await indexer.close();
2962

3063
spinner.stop();
@@ -66,6 +99,36 @@ export const statsCommand = new Command('stats')
6699
logger.warn(`${stats.errors.length} error(s) during last indexing`);
67100
}
68101

102+
// Display GitHub stats if available
103+
if (githubStats) {
104+
logger.log('');
105+
logger.log(chalk.bold.cyan('🔗 GitHub Integration'));
106+
logger.log('');
107+
logger.log(`${chalk.cyan('Repository:')} ${githubStats.repository}`);
108+
logger.log(`${chalk.cyan('Total Documents:')} ${githubStats.totalDocuments}`);
109+
logger.log(`${chalk.cyan('Issues:')} ${githubStats.byType.issue || 0}`);
110+
logger.log(`${chalk.cyan('Pull Requests:')} ${githubStats.byType.pull_request || 0}`);
111+
logger.log('');
112+
logger.log(`${chalk.cyan('Open:')} ${githubStats.byState.open || 0}`);
113+
logger.log(`${chalk.cyan('Closed:')} ${githubStats.byState.closed || 0}`);
114+
if (githubStats.byState.merged) {
115+
logger.log(`${chalk.cyan('Merged:')} ${githubStats.byState.merged}`);
116+
}
117+
logger.log('');
118+
logger.log(
119+
`${chalk.cyan('Last Synced:')} ${new Date(githubStats.lastIndexed).toLocaleString()}`
120+
);
121+
} else {
122+
logger.log('');
123+
logger.log(chalk.bold.cyan('🔗 GitHub Integration'));
124+
logger.log('');
125+
logger.log(
126+
chalk.gray('Not indexed. Run') +
127+
chalk.yellow(' dev gh index ') +
128+
chalk.gray('to sync GitHub data.')
129+
);
130+
}
131+
69132
logger.log('');
70133
} catch (error) {
71134
spinner.fail('Failed to load statistics');

packages/mcp-server/bin/dev-agent-mcp.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ async function main() {
6262
const githubAdapter = new GitHubAdapter({
6363
repositoryPath,
6464
// GitHubIndexer will be lazily initialized on first use
65-
vectorStorePath: `${repositoryPath}/.dev-agent/github-vectors.lance`,
65+
vectorStorePath: `${vectorStorePath}-github`,
6666
statePath: `${repositoryPath}/.dev-agent/github-state.json`,
6767
defaultLimit: 10,
6868
defaultFormat: 'compact',

packages/mcp-server/src/adapters/__tests__/github-adapter.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,32 @@ describe('GitHubAdapter', () => {
231231
'No matching issues or PRs found'
232232
);
233233
});
234+
235+
it('should include token footer in search results', async () => {
236+
const mockResults: GitHubSearchResult[] = [
237+
{
238+
document: mockIssue,
239+
score: 0.9,
240+
matchedFields: ['title'],
241+
},
242+
];
243+
244+
vi.mocked(mockGitHubIndexer.search).mockResolvedValue(mockResults);
245+
246+
const result = await adapter.execute(
247+
{
248+
action: 'search',
249+
query: 'test',
250+
format: 'compact',
251+
},
252+
mockContext
253+
);
254+
255+
expect(result.success).toBe(true);
256+
const content = (result.data as { content: string })?.content;
257+
expect(content).toContain('🪙');
258+
expect(content).toMatch(/~\d+ tokens$/);
259+
});
234260
});
235261

236262
describe('Context Action', () => {

packages/mcp-server/src/adapters/__tests__/status-adapter.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,8 @@ describe('StatusAdapter', () => {
106106
});
107107

108108
await adapter.initialize(mockContext);
109-
expect(mockContext.logger.debug).toHaveBeenCalledWith(
110-
'GitHub indexer not available',
109+
expect(mockContext.logger.warn).toHaveBeenCalledWith(
110+
'GitHub indexer initialization failed',
111111
expect.any(Object)
112112
);
113113
});

packages/mcp-server/src/adapters/built-in/github-adapter.ts

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
GitHubSearchOptions,
1010
GitHubSearchResult,
1111
} from '@lytics/dev-agent-subagents';
12+
import { estimateTokensForText } from '../../formatters/utils';
1213
import { ToolAdapter } from '../tool-adapter';
1314
import type { AdapterContext, ToolDefinition, ToolExecutionContext, ToolResult } from '../types';
1415

@@ -85,11 +86,26 @@ export class GitHubAdapter extends ToolAdapter {
8586
// Lazy initialization
8687
const { GitHubIndexer: GitHubIndexerClass } = await import('@lytics/dev-agent-subagents');
8788

88-
this.githubIndexer = new GitHubIndexerClass({
89-
vectorStorePath: this.vectorStorePath,
90-
statePath: this.statePath,
91-
autoUpdate: false,
92-
});
89+
// Try to load repository from state file to avoid gh CLI call
90+
let repository: string | undefined;
91+
try {
92+
const fs = await import('node:fs/promises');
93+
const stateContent = await fs.readFile(this.statePath, 'utf-8');
94+
const state = JSON.parse(stateContent);
95+
repository = state.repository;
96+
} catch {
97+
// State file doesn't exist or can't be read
98+
// GitHubIndexer will try gh CLI as fallback
99+
}
100+
101+
this.githubIndexer = new GitHubIndexerClass(
102+
{
103+
vectorStorePath: this.vectorStorePath,
104+
statePath: this.statePath,
105+
autoUpdate: false,
106+
},
107+
repository // Pass repository to avoid gh CLI call
108+
);
93109

94110
await this.githubIndexer.initialize();
95111
return this.githubIndexer;
@@ -306,7 +322,10 @@ export class GitHubAdapter extends ToolAdapter {
306322
const results = await indexer.search(query, options);
307323

308324
if (results.length === 0) {
309-
return '## GitHub Search Results\n\nNo matching issues or PRs found. Try:\n- Using different keywords\n- Removing filters (type, state, labels)\n- Re-indexing GitHub data with "dev gh index"';
325+
const noResultsMsg =
326+
'## GitHub Search Results\n\nNo matching issues or PRs found. Try:\n- Using different keywords\n- Removing filters (type, state, labels)\n- Re-indexing GitHub data with "dev gh index"';
327+
const tokens = estimateTokensForText(noResultsMsg);
328+
return `${noResultsMsg}\n\n🪙 ~${tokens} tokens`;
310329
}
311330

312331
if (format === 'verbose') {
@@ -403,7 +422,9 @@ export class GitHubAdapter extends ToolAdapter {
403422
lines.push('', `_...and ${results.length - 5} more results_`);
404423
}
405424

406-
return lines.join('\n');
425+
const content = lines.join('\n');
426+
const tokens = estimateTokensForText(content);
427+
return `${content}\n\n🪙 ~${tokens} tokens`;
407428
}
408429

409430
/**
@@ -447,7 +468,9 @@ export class GitHubAdapter extends ToolAdapter {
447468
lines.push('');
448469
}
449470

450-
return lines.join('\n');
471+
const content = lines.join('\n');
472+
const tokens = estimateTokensForText(content);
473+
return `${content}\n\n🪙 ~${tokens} tokens`;
451474
}
452475

453476
/**
@@ -474,7 +497,9 @@ export class GitHubAdapter extends ToolAdapter {
474497
`**URL:** ${doc.url}`,
475498
].filter(Boolean) as string[];
476499

477-
return lines.join('\n');
500+
const content = lines.join('\n');
501+
const tokens = estimateTokensForText(content);
502+
return `${content}\n\n🪙 ~${tokens} tokens`;
478503
}
479504

480505
/**
@@ -518,7 +543,9 @@ export class GitHubAdapter extends ToolAdapter {
518543
`**URL:** ${doc.url}`,
519544
].filter(Boolean) as string[];
520545

521-
return lines.join('\n');
546+
const content = lines.join('\n');
547+
const tokens = estimateTokensForText(content);
548+
return `${content}\n\n🪙 ~${tokens} tokens`;
522549
}
523550

524551
/**

packages/mcp-server/src/adapters/built-in/status-adapter.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,15 +74,29 @@ export class StatusAdapter extends ToolAdapter {
7474

7575
// Initialize GitHub indexer lazily
7676
try {
77-
this.githubIndexer = new GitHubIndexer({
78-
vectorStorePath: `${this.vectorStorePath}-github`,
79-
statePath: '.dev-agent/github-state.json',
80-
autoUpdate: true,
81-
staleThreshold: 15 * 60 * 1000,
82-
});
77+
// Try to load repository from state file
78+
let repository: string | undefined;
79+
const statePath = path.join(this.repositoryPath, '.dev-agent/github-state.json');
80+
try {
81+
const stateContent = await fs.promises.readFile(statePath, 'utf-8');
82+
const state = JSON.parse(stateContent);
83+
repository = state.repository;
84+
} catch {
85+
// State file doesn't exist, will try gh CLI
86+
}
87+
88+
this.githubIndexer = new GitHubIndexer(
89+
{
90+
vectorStorePath: `${this.vectorStorePath}-github`,
91+
statePath, // Use absolute path
92+
autoUpdate: true,
93+
staleThreshold: 15 * 60 * 1000,
94+
},
95+
repository
96+
);
8397
await this.githubIndexer.initialize();
8498
} catch (error) {
85-
context.logger.debug('GitHub indexer not available', { error });
99+
context.logger.warn('GitHub indexer initialization failed', { error });
86100
// Not fatal, GitHub section will show "not indexed"
87101
}
88102
}

packages/mcp-server/src/formatters/__tests__/formatters.test.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,14 +70,17 @@ describe('Formatters', () => {
7070
expect(result.content).toContain('2. [84%]');
7171
expect(result.content).toContain('3. [72%]');
7272
expect(result.tokenEstimate).toBeGreaterThan(0);
73+
// Should include token footer
74+
expect(result.content).toMatch(/🪙 ~\d+ tokens$/);
7375
});
7476

7577
it('should respect maxResults option', () => {
7678
const formatter = new CompactFormatter({ maxResults: 2 });
7779
const result = formatter.formatResults(mockResults);
7880

79-
const lines = result.content.split('\n');
80-
expect(lines).toHaveLength(2); // Only 2 results
81+
// Count actual result lines (numbered lines)
82+
const resultLines = result.content.split('\n').filter((l) => /^\d+\./.test(l));
83+
expect(resultLines).toHaveLength(2); // Only 2 results
8184
});
8285

8386
it('should exclude signatures by default', () => {
@@ -139,6 +142,9 @@ describe('Formatters', () => {
139142

140143
// Should have double newlines between results
141144
expect(result.content).toContain('\n\n');
145+
146+
// Should include token footer
147+
expect(result.content).toMatch(/🪙 ~\d+ tokens$/);
142148
});
143149

144150
it('should include signatures by default', () => {
@@ -215,4 +221,34 @@ describe('Formatters', () => {
215221
expect(threeResults.tokenEstimate).toBeGreaterThan(oneResult.tokenEstimate * 2);
216222
});
217223
});
224+
225+
describe('Token Footer', () => {
226+
it('compact formatter should include coin emoji footer', () => {
227+
const formatter = new CompactFormatter();
228+
const result = formatter.formatResults(mockResults);
229+
230+
expect(result.content).toContain('🪙');
231+
expect(result.content).toMatch(/~\d+ tokens$/);
232+
});
233+
234+
it('verbose formatter should include coin emoji footer', () => {
235+
const formatter = new VerboseFormatter();
236+
const result = formatter.formatResults(mockResults);
237+
238+
expect(result.content).toContain('🪙');
239+
expect(result.content).toMatch(/~\d+ tokens$/);
240+
});
241+
242+
it('token footer should match tokenEstimate property', () => {
243+
const formatter = new CompactFormatter();
244+
const result = formatter.formatResults(mockResults);
245+
246+
// Extract token count from footer
247+
const footerMatch = result.content.match(/🪙 ~(\d+) tokens$/);
248+
expect(footerMatch).toBeTruthy();
249+
250+
const footerTokens = Number.parseInt(footerMatch![1], 10);
251+
expect(footerTokens).toBe(result.tokenEstimate);
252+
});
253+
});
218254
});

packages/mcp-server/src/formatters/__tests__/utils.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,5 +155,47 @@ describe('Formatter Utils', () => {
155155
expect(ratio).toBeLessThan(2);
156156
}
157157
});
158+
159+
it('should use calibrated 4.5 chars per token formula', () => {
160+
// Test the calibrated formula matches actual usage
161+
// Known: 803 chars normalized = 178 tokens actual
162+
const testText = '## GitHub Search Results\n'.repeat(20); // ~520 chars
163+
const normalized = testText.trim().replace(/\s+/g, ' ');
164+
const estimate = estimateTokensForText(testText);
165+
const expectedFromFormula = Math.ceil(normalized.length / 4.5);
166+
167+
// Should use the calibrated 4.5 ratio
168+
expect(estimate).toBeGreaterThanOrEqual(expectedFromFormula - 5);
169+
expect(estimate).toBeLessThanOrEqual(expectedFromFormula + 5);
170+
});
171+
172+
it('should estimate within 5% for technical content', () => {
173+
// Real test case from actual usage (full text)
174+
const technicalText = `## GitHub Search Results
175+
**Query:** "token estimation and cost tracking"
176+
**Total Found:** 3
177+
178+
1. [Score: 29.6%] function: estimateTokensForText
179+
Location: packages/mcp-server/src/formatters/utils.ts:15
180+
Signature: export function estimateTokensForText(text: string): number
181+
Metadata: language: typescript, exported: true, lines: 19
182+
183+
2. [Score: 21.0%] function: estimateTokensForJSON
184+
Location: packages/mcp-server/src/formatters/utils.ts:63
185+
Signature: export function estimateTokensForJSON(obj: unknown): number
186+
Metadata: language: typescript, exported: true, lines: 4
187+
188+
3. [Score: 19.7%] method: VerboseFormatter.estimateTokens
189+
Location: packages/mcp-server/src/formatters/verbose-formatter.ts:114
190+
Signature: estimateTokens(result: SearchResult): number
191+
Metadata: language: typescript, exported: true, lines: 3`;
192+
193+
const estimate = estimateTokensForText(technicalText);
194+
const actualTokens = 178; // Verified from Cursor
195+
196+
// Should be within 5% of actual (calibrated at 0.6%)
197+
const errorPercent = Math.abs((estimate - actualTokens) / actualTokens) * 100;
198+
expect(errorPercent).toBeLessThan(5);
199+
});
158200
});
159201
});

packages/mcp-server/src/formatters/compact-formatter.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,11 @@ export class CompactFormatter implements ResultFormatter {
7272
});
7373

7474
// Calculate total tokens
75-
const content = formatted.join('\n');
76-
const tokenEstimate = estimateTokensForText(content);
75+
const contentLines = formatted.join('\n');
76+
const tokenEstimate = estimateTokensForText(contentLines);
77+
78+
// Add token footer
79+
const content = `${contentLines}\n\n🪙 ~${tokenEstimate} tokens`;
7780

7881
return {
7982
content,

0 commit comments

Comments
 (0)