Skip to content

Commit 3eff70a

Browse files
author
Евгений Балякин
committed
enhance Python MCP context discovery
1 parent 8e451c2 commit 3eff70a

8 files changed

Lines changed: 122 additions & 11 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codebone",
3-
"version": "0.1.0",
3+
"version": "0.1.1",
44
"description": "Agent-native CLI and MCP server for compact code context.",
55
"type": "module",
66
"bin": {

src/core/context.ts

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { estimateTokens } from './budget.js';
99
import { resolveImport, summarizeGraph } from './graph.js';
1010
import { buildIndex } from './indexer.js';
1111
import { readCode } from './reader.js';
12-
import { skeletonSourceAsync } from './skeleton.js';
12+
import { flattenSymbols, skeletonSourceAsync } from './skeleton.js';
1313

1414
const execFileAsync = promisify(execFile);
1515

@@ -19,6 +19,7 @@ export interface ContextOptions {
1919
budget?: number;
2020
includeTests?: boolean;
2121
changedOnly?: boolean;
22+
mode?: 'full' | 'architecture';
2223
}
2324

2425
export async function buildContext(root: string, options: ContextOptions) {
@@ -33,7 +34,7 @@ export async function buildContext(root: string, options: ContextOptions) {
3334
const changedFiles = await getChangedFiles(root);
3435
const files = (await walkSourceFiles(root, options.path ?? '.', { maxFiles: 1000 })).filter((file) => !options.changedOnly || changedFiles.has(file.relativePath));
3536
const ranked = [] as Array<{ path: string; score: number; reason: string; tokens: number; content: string; symbolId?: string }>;
36-
const fileRecords = [] as Array<{ path: string; source: string; imports: string[]; exported: Array<{ name: string; kind: string }>; symbolText: string; tokens: number; size: number; content: string; symbolId?: string }>;
37+
const fileRecords = [] as Array<{ path: string; source: string; imports: string[]; exported: Array<{ name: string; kind: string }>; symbols: ReturnType<typeof flattenSymbols>; symbolText: string; tokens: number; size: number; content: string; symbolId?: string }>;
3738
const omittedFiles = [] as Array<{ path: string; reason: string }>;
3839
for (const file of files) {
3940
if (options.includeTests === false && /(^|\/)(test|tests|spec|__tests__)(\/|$)|\.(test|spec)\./.test(file.relativePath)) {
@@ -43,8 +44,9 @@ export async function buildContext(root: string, options: ContextOptions) {
4344
try {
4445
const { text: source } = await readTextFileSafe(file.absolutePath, undefined, root);
4546
const skeleton = await skeletonSourceAsync(root, file.relativePath, source, { budget: Math.min(2000, budget) });
47+
const symbols = flattenSymbols(skeleton.symbols);
4648
const content = JSON.stringify(skeleton, null, 2);
47-
fileRecords.push({ path: file.relativePath, source, imports: skeleton.symbols.filter((symbol) => symbol.kind === 'import').map((symbol) => symbol.source ?? symbol.signature), exported: skeleton.symbols.filter((symbol) => symbol.exported).map((symbol) => ({ name: symbol.qualifiedName, kind: symbol.kind })), symbolText: skeleton.symbols.map((symbol) => `${symbol.name} ${symbol.signature}`).join('\n'), tokens: skeleton.tokenEstimate, size: file.size, content, symbolId: skeleton.symbols.find((symbol) => symbol.kind !== 'import')?.symbolId });
49+
fileRecords.push({ path: file.relativePath, source, imports: symbols.filter((symbol) => symbol.kind === 'import').map((symbol) => symbol.source ?? symbol.signature), exported: symbols.filter((symbol) => symbol.exported).map((symbol) => ({ name: symbol.qualifiedName, kind: symbol.kind })), symbols, symbolText: symbols.map((symbol) => `${symbol.name} ${symbol.signature}`).join('\n'), tokens: skeleton.tokenEstimate, size: file.size, content, symbolId: symbols.find((symbol) => symbol.kind !== 'import')?.symbolId });
4850
} catch (error) {
4951
const message = error instanceof Error ? error.message : String(error);
5052
const reason = /File too large/i.test(message) ? 'file_too_large' : /Binary/i.test(message) ? 'unsupported_binary' : `parse_or_read_error:${message}`;
@@ -104,6 +106,14 @@ export async function buildContext(root: string, options: ContextOptions) {
104106
ranked.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
105107
const items = [] as Array<{ type: 'skeleton' | 'symbol_body'; path: string; score: number; reason: string; content: string; symbolId?: string }>;
106108
let usedTokens = 0;
109+
const testRelations = inferTestRelations(fileRecords, graph.edges).slice(0, 20);
110+
if (options.mode === 'architecture') {
111+
const architecture = buildArchitectureSummary(fileRecords, graph.edges, testRelations);
112+
const content = renderArchitectureSummary(architecture);
113+
const omitted = omittedFiles.slice(0, 20);
114+
const data = { schemaVersion: SCHEMA_VERSION, goal: options.goal, mode: 'architecture' as const, budget, usedTokens: estimateTokens(content), items: [{ type: 'architecture_summary' as const, path: options.path ?? '.', score: 1, reason: 'compact architecture summary', content }], omitted, nextReads: ranked.slice(0, 10).map((item) => ({ command: 'skeleton', path: item.path, symbolId: item.symbolId })), architecture, testRelations, warnings, truncated: omitted.length > 0, tokenEstimate: estimateTokens(content) };
115+
return data;
116+
}
107117
for (const item of ranked) {
108118
if (usedTokens + item.tokens > budget) break;
109119
items.push({ type: 'skeleton', path: item.path, score: Number(item.score.toFixed(2)), reason: item.reason, content: item.content });
@@ -123,8 +133,7 @@ export async function buildContext(root: string, options: ContextOptions) {
123133
const nextReads = ranked.slice(0, 5).map((item) => ({ command: item.symbolId ? 'read' : 'skeleton', path: item.path, symbolId: item.symbolId }));
124134
const included = new Set(items.map((item) => `${item.type}:${item.path}:${item.symbolId ?? ''}`));
125135
const omitted = [...ranked.filter((item) => !included.has(`skeleton:${item.path}:`)).slice(0, 20).map((item) => ({ path: item.path, reason: 'budget' })), ...omittedFiles.slice(0, 20)];
126-
const testRelations = inferTestRelations(fileRecords, graph.edges).slice(0, 20);
127-
const data = { schemaVersion: SCHEMA_VERSION, goal: options.goal, budget, usedTokens, items, omitted, nextReads, testRelations, warnings, truncated: omitted.length > 0, tokenEstimate: estimateTokens(JSON.stringify(items)) };
136+
const data = { schemaVersion: SCHEMA_VERSION, goal: options.goal, mode: 'full' as const, budget, usedTokens, items, omitted, nextReads, testRelations, warnings, truncated: omitted.length > 0, tokenEstimate: estimateTokens(JSON.stringify(items)) };
128137
return data;
129138
}
130139

@@ -178,7 +187,58 @@ function isTestPath(filePath: string): boolean {
178187
return /(^|\/)(test|tests|spec|__tests__)(\/|$)|\.(test|spec)\.|(^|\/)test_[^/]+\.py$|(^|\/)[^/]+_test\.py$/.test(filePath);
179188
}
180189

190+
function buildArchitectureSummary(fileRecords: Array<{ path: string; imports: string[]; symbols: ReturnType<typeof flattenSymbols> }>, edges: Array<{ from: string; source: string; resolved?: string }>, testRelations: Array<{ test: string; source: string; reason: string }>) {
191+
const files = fileRecords.map((record) => ({
192+
path: record.path,
193+
classes: record.symbols.filter((symbol) => symbol.kind === 'class').map((symbol) => symbol.qualifiedName),
194+
functions: record.symbols.filter((symbol) => symbol.kind === 'function' && !symbol.name.startsWith('_')).map((symbol) => symbol.qualifiedName),
195+
rpcMethods: record.symbols.filter((symbol) => ['function', 'method'].includes(symbol.kind) && symbol.name.startsWith('rpc_')).map((symbol) => symbol.qualifiedName),
196+
})).filter((file) => file.classes.length || file.functions.length || file.rpcMethods.length);
197+
198+
const routes = fileRecords.flatMap((record) => record.symbols.filter((symbol) => symbol.kind === 'route').map((route) => {
199+
const handler = record.symbols
200+
.filter((symbol) => ['function', 'method'].includes(symbol.kind) && symbol.startLine > route.startLine)
201+
.sort((a, b) => a.startLine - b.startLine)[0];
202+
return { file: record.path, route: route.name, line: route.startLine, handler: handler?.qualifiedName };
203+
}));
204+
205+
const dependencies = uniqueBy(fileRecords.flatMap((record) => record.symbols.filter((symbol) => symbol.kind === 'dependency').map((symbol) => ({ file: record.path, key: symbol.name, line: symbol.startLine }))), (item) => `${item.file}:${item.key}:${item.line}`);
206+
const tables = uniqueBy(fileRecords.flatMap((record) => record.symbols.filter((symbol) => symbol.kind === 'table').map((symbol) => ({ file: record.path, name: symbol.name, line: symbol.startLine }))), (item) => `${item.file}:${item.name}`);
207+
const localImports = edges.filter((edge) => edge.resolved).map((edge) => ({ from: edge.from, to: edge.resolved!, source: edge.source }));
208+
const serviceEdges = localImports.filter((edge) => /service|api|route|handler|view|dao|task|worker|creator/i.test(`${edge.from} ${edge.to} ${edge.source}`));
209+
return { files, routes, dependencies, tables, serviceEdges: serviceEdges.slice(0, 30), testRelations };
210+
}
211+
212+
function renderArchitectureSummary(summary: ReturnType<typeof buildArchitectureSummary>): string {
213+
const sections: string[] = ['Architecture summary'];
214+
if (summary.routes.length) sections.push(`Routes -> handlers:\n${summary.routes.map((item) => ` ${item.route} -> ${item.handler ?? 'unknown'} (${item.file}:${item.line})`).join('\n')}`);
215+
if (summary.dependencies.length) sections.push(`App dependencies:\n${summary.dependencies.map((item) => ` app["${item.key}"] (${item.file}:${item.line})`).join('\n')}`);
216+
if (summary.tables.length) sections.push(`SQLAlchemy tables:\n${summary.tables.map((item) => ` ${item.name} (${item.file}:${item.line})`).join('\n')}`);
217+
if (summary.serviceEdges.length) sections.push(`Local dependency flow:\n${summary.serviceEdges.map((item) => ` ${item.from} -> ${item.to}`).join('\n')}`);
218+
if (summary.files.length) sections.push(`Key files:\n${summary.files.slice(0, 30).map((item) => {
219+
const parts = [
220+
item.classes.length ? `classes: ${item.classes.slice(0, 6).join(', ')}` : '',
221+
item.rpcMethods.length ? `rpc: ${item.rpcMethods.slice(0, 6).join(', ')}` : '',
222+
item.functions.length ? `functions: ${item.functions.slice(0, 6).join(', ')}` : '',
223+
].filter(Boolean).join('; ');
224+
return ` ${item.path}${parts ? ` - ${parts}` : ''}`;
225+
}).join('\n')}`);
226+
if (summary.testRelations.length) sections.push(`Suggested tests:\n${summary.testRelations.slice(0, 15).map((item) => ` ${item.source} -> ${item.test} (${item.reason})`).join('\n')}`);
227+
return sections.join('\n\n');
228+
}
229+
230+
function uniqueBy<T>(items: T[], key: (item: T) => string): T[] {
231+
const seen = new Set<string>();
232+
return items.filter((item) => {
233+
const value = key(item);
234+
if (seen.has(value)) return false;
235+
seen.add(value);
236+
return true;
237+
});
238+
}
239+
181240
export function renderContext(data: Awaited<ReturnType<typeof buildContext>>): string {
241+
if (data.mode === 'architecture') return data.items[0]?.content ?? 'Architecture summary: empty';
182242
const relatedTests = data.testRelations.length ? `\n\nRelated tests:\n${data.testRelations.slice(0, 10).map((item) => ` ${item.test} -> ${item.source} (${item.reason})`).join('\n')}` : '';
183243
return `Context pack: ${data.usedTokens} tokens, ${data.items.length} included\n\n${data.items.map((item, index) => `${index + 1}. ${item.path} ${item.type} (${item.reason})`).join('\n')}\n\nNext reads:\n${data.nextReads.map((item) => ` codebone ${item.command} ${item.path}`).join('\n')}${relatedTests}`;
184244
}

src/core/indexer.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ interface FileShard {
2222

2323
export async function buildIndex(root: string, inputPath = '.', options: { clear?: boolean } = {}) {
2424
const indexRoot = path.join(root, '.codebone', 'index.v1');
25+
await ensureIndexIgnored(root);
2526
if (options.clear) await fs.rm(indexRoot, { recursive: true, force: true });
2627
await fs.mkdir(path.join(indexRoot, 'files'), { recursive: true });
2728
await fs.mkdir(path.join(indexRoot, 'dictionaries'), { recursive: true });
@@ -111,6 +112,17 @@ export async function buildIndex(root: string, inputPath = '.', options: { clear
111112
return { ...manifest, schemaVersion: SCHEMA_VERSION, indexPath: '.codebone/index.v1', warnings: [], truncated: false, tokenEstimate: 100 };
112113
}
113114

115+
async function ensureIndexIgnored(root: string): Promise<void> {
116+
const excludePath = path.join(root, '.git', 'info', 'exclude');
117+
try {
118+
const current = await fs.readFile(excludePath, 'utf8');
119+
if (/^\.codebone\/\s*$/m.test(current)) return;
120+
await fs.appendFile(excludePath, `${current.endsWith('\n') ? '' : '\n'}.codebone/\n`);
121+
} catch {
122+
// Non-git worktrees or read-only .git directories can still use the index.
123+
}
124+
}
125+
114126
export async function indexStatus(root: string) {
115127
try {
116128
const manifest = JSON.parse(await fs.readFile(path.join(root, '.codebone', 'index.v1', 'manifest.json'), 'utf8')) as Record<string, unknown>;

src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,9 @@ program.command('context')
9090
.option('--budget <tokens>', 'token budget', '8000')
9191
.option('--include-tests', 'include tests')
9292
.option('--changed-only', 'changed only')
93+
.option('--mode <mode>', 'full or architecture', 'full')
9394
.action(async (options) => run('context', async (root, format, startedAt) => {
94-
const data = await buildContext(root, { goal: options.goal, path: options.path, budget: Number(options.budget), includeTests: options.includeTests, changedOnly: options.changedOnly });
95+
const data = await buildContext(root, { goal: options.goal, path: options.path, budget: Number(options.budget), includeTests: options.includeTests, changedOnly: options.changedOnly, mode: options.mode === 'architecture' ? 'architecture' : 'full' });
9596
printResult(format, renderContext(data), envelope(root, { command: 'context', goal: options.goal }, data, startedAt));
9697
}));
9798

src/mcp-server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ async function callTool(projectRoot: string, name: string, args: Record<string,
110110
}
111111
if (name === 'codebone_symbols') return findSymbols(projectRoot, String(args.path ?? '.'), { query: String(args.query), kind: String(args.kind ?? 'all') as never, exact: args.exact === undefined ? undefined : Boolean(args.exact), fuzzy: Boolean(args.fuzzy), limit: Number(args.limit ?? 100), includeImports: args.includeImports !== false });
112112
if (name === 'codebone_read') return readCode(projectRoot, String(args.path), { symbolId: args.symbolId as string | undefined, symbol: args.symbol as string | undefined, lines: args.lines as string | undefined, context: Number(args.context ?? 0), maxBytes: Number(args.maxBytes ?? 65536) });
113-
if (name === 'codebone_context') return buildContext(projectRoot, { goal: String(args.goal), path: String(args.path ?? '.'), budget: Number(args.budget ?? 8000), includeTests: args.includeTests !== false, changedOnly: Boolean(args.changedOnly) });
113+
if (name === 'codebone_context') return buildContext(projectRoot, { goal: String(args.goal), path: String(args.path ?? '.'), budget: Number(args.budget ?? 8000), includeTests: args.includeTests !== false, changedOnly: Boolean(args.changedOnly), mode: args.mode === 'architecture' ? 'architecture' : 'full' });
114114
if (name === 'codebone_batch') return runBatch(projectRoot, (args.operations ?? []) as Array<Record<string, unknown>>);
115115
if (name === 'codebone_index') return buildIndex(projectRoot, String(args.path ?? '.'), { clear: Boolean(args.clear) });
116116
if (name === 'codebone_doctor') return doctor(projectRoot);
@@ -225,7 +225,7 @@ const tools = [
225225
{ name: 'codebone_skeleton', description: 'Get the structural skeleton of a source file or directory: all function signatures, class definitions, imports, and type declarations with line numbers. Bodies of functions are omitted. Use mode:"summary" for large Python directories to focus on files, public classes/functions, routes, tables, and key methods.', inputSchema: schema({ path: 'string', publicOnly: 'boolean', maxFiles: 'integer', budget: 'integer', changedOnly: 'boolean', mode: 'string' }, ['path']), outputSchema: outputSchema() },
226226
{ name: 'codebone_symbols', description: 'Search for a symbol (function, class, variable, type) by name across the entire project. Returns definitions and references with file paths and line numbers. Use this to find where something is defined or used.', inputSchema: schema({ query: 'string', path: 'string', kind: 'string', exact: 'boolean', fuzzy: 'boolean', limit: 'integer', includeImports: 'boolean' }, ['query']), outputSchema: outputSchema() },
227227
{ name: 'codebone_read', description: 'Read a specific symbol body or line range. Prefer symbolId from codebone_skeleton or codebone_symbols over fuzzy symbol names.', inputSchema: schema({ path: 'string', symbolId: 'string', symbol: 'string', lines: 'string', context: 'integer', maxBytes: 'integer' }, ['path']), outputSchema: outputSchema() },
228-
{ name: 'codebone_context', description: 'Build a ranked context pack for a concrete coding task under a token budget. Use before editing when you need to understand related files.', inputSchema: schema({ goal: 'string', path: 'string', budget: 'integer', includeTests: 'boolean', changedOnly: 'boolean' }, ['goal']), outputSchema: outputSchema() },
228+
{ name: 'codebone_context', description: 'Build a ranked context pack for a concrete coding task under a token budget. Use mode:"architecture" for compact Python service summaries: routes, app dependencies, tables, local dependency flow, and suggested tests.', inputSchema: schema({ goal: 'string', path: 'string', budget: 'integer', includeTests: 'boolean', changedOnly: 'boolean', mode: 'string' }, ['goal']), outputSchema: outputSchema() },
229229
{ name: 'codebone_batch', description: 'Execute multiple read-only codebone operations in one call. Use to minimize round-trips when you need several skeleton/read/symbol/context results.', inputSchema: { type: 'object', properties: { operations: { type: 'array', items: { type: 'object' } } }, required: ['operations'], additionalProperties: false }, outputSchema: outputSchema() },
230230
{ name: 'codebone_index', description: 'Build or update the local project symbol index for faster searches and context packs.', inputSchema: schema({ path: 'string', clear: 'boolean' }), outputSchema: outputSchema() },
231231
{ name: 'codebone_doctor', description: 'Check codebone installation, parser availability, index writability, and MCP stdio readiness.', inputSchema: schema({}), outputSchema: outputSchema() },

src/mcp/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const descriptions: Record<string, string> = {
3434
includeTests: 'Include test and spec files in context selection.',
3535
changedOnly: 'Only include files changed in git status.',
3636
clear: 'Remove the previous index before rebuilding.',
37+
mode: 'Output mode. Use architecture for compact Python service summaries where supported.',
3738
};
3839

3940
const defaults: Record<string, unknown> = {

0 commit comments

Comments
 (0)