Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ The method and failures are documented so the measurements can be inspected with
- The paired token observation covers two frozen investigation tasks and records observed agent behavior; it is not a universal token or time guarantee.
- Setup checks differ by client. The detailed guide distinguishes a written config, a config recognized by the client, a local connection, and instructions checked only against official docs.
- Dedicated Angular, React, and Next.js analyzers ship in published 2.2.0; NestJS support is planned for unpublished 2.4.0. Other projects use the generic analyzer and the language parsers available for that stack.
- The default searchable-chunk limit is 5,000 per project. Customizing this via `projects[].parsing.maxChunks` in `~/.codebase-context/config.json` requires 2.4.0 (not yet published).
- The default searchable-chunk limit is 5,000 per project. Raise it with `projects[].parsing.maxChunks` in `~/.codebase-context/config.json` (see [capabilities](docs/capabilities.md)).
- The agent must identify its repository in tool calls when no default or unambiguous client root is available. Concurrent HTTP client isolation is not established by the stdio routing checks.

## Reference
Expand Down
2 changes: 1 addition & 1 deletion docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Per-project config overrides supported today:
- `projects[].excludePatterns`: merged with the built-in exclusion set for that project at index time
- `projects[].analyzerHints.analyzer`: prefers a registered analyzer by name for that project and falls back safely when the name is missing or invalid
- `projects[].analyzerHints.extensions`: adds project-local source extensions for indexing and auto-refresh watching without changing defaults for other projects
- `projects[].parsing.maxChunks`: sets the maximum number of searchable chunks indexed for that project (requires 2.4.0, not yet published)
- `projects[].parsing.maxChunks`: sets the maximum number of searchable chunks indexed for that project (default 5000; configurable via `~/.codebase-context/config.json`)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Published users get ineffective advice

Removing the unreleased label presents projects[].parsing.maxChunks as available to npm users. The guide says unmarked features apply to the published package, but the v2.3.0 config reader does not read parsing; this option was added later. Keep an unreleased or built-checkout qualifier here and in README.md. The repository requires public docs not to claim capabilities that are not shipped, so this must be corrected before merging.

Context Used: AGENTS.md (source)


Copy-pasteable client config templates are shipped in the package:

Expand Down
8 changes: 2 additions & 6 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import path from 'path';
import { promises as fs } from 'fs';
import { KEYWORD_INDEX_FILENAME } from './constants/codebase-context.js';
import { makePaths } from './project-state.js';
import { CodebaseIndexer } from './core/indexer.js';
import { CodebaseIndexer, formatIndexingCompleteLine } from './core/indexer.js';
import { dispatchTool } from './tools/index.js';
import type { ToolContext } from './tools/index.js';
import type { IndexState } from './tools/types.js';
Expand Down Expand Up @@ -155,11 +155,7 @@ async function initToolContext(): Promise<ToolContext> {
indexState.lastIndexed = new Date();
indexState.stats = stats;

console.error(
`Complete: ${stats.indexedFiles} files, ${stats.totalChunks} chunks in ${(
stats.duration / 1000
).toFixed(2)}s`
);
console.error(formatIndexingCompleteLine(stats));
} catch (error) {
indexState.status = 'error';
indexState.error = error instanceof Error ? error.message : String(error);
Expand Down
6 changes: 5 additions & 1 deletion src/core/codebase-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ const MAP_EXCLUDED_PATH_PATTERNS = [
/(?:^|\/)(?:fixtures?|__fixtures__)(?:\/|$)/i,
/(?:^|\/)(?:generated|__generated__)(?:\/|$)/i,
/(?:^|\/)[^/]*\.(?:generated|gen|min)\.[^/]+$/i,
/(?:^|\/)raw(?:\/|$)/i,
/(?:^|\/)live-from-ai(?:\/|$)/i,
/[^/]+-[A-Za-z0-9]{6,}\.(?:js|css|mjs|cjs)$/i,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Distinguish bundle hashes from ordinary hyphenated filenames

In the default bounded map, this pattern also excludes handwritten files whose final hyphen-separated word has six or more letters. For example, the existing scripts/sync-grammars.mjs matches it, as would request-handler.js. Because isMapEligiblePath filters graph nodes, edges, and chunks, these legitimate source files disappear from architecture sections rather than just generated bundles being removed. Narrow the bundle detection so ordinary descriptive filenames remain eligible.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Ordinary source files disappear

The bundle pattern also matches maintained source such as query-builder.js, error-handler.js, and scripts/check-agent-instructions.mjs. The default bounded map removes these files and their graph edges, hiding useful source from the map. Restrict the exclusion to paths with evidence that they are generated bundles, and test an ordinary hyphenated filename.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

/\.snap$/i
] as const;

Expand Down Expand Up @@ -180,6 +183,7 @@ async function buildPinnedCodebaseMap(
const entrypoints: string[] = [];
for (const [file, imports] of Object.entries(graphImports)) {
if (imports.length === 0) continue; // no imports — not an entrypoint
if (!isMapEligiblePath(file, mode)) continue;
const importers = graphImportedBy[file];
if (!importers || importers.length === 0) {
entrypoints.push(file);
Expand All @@ -193,7 +197,7 @@ async function buildPinnedCodebaseMap(
graphImportedBy
).map(([file, importers]) => ({ file, count: importers.length }));
const hubFiles: string[] = sortByCountThenAlpha(
importedByCounts,
importedByCounts.filter(({ file }) => isMapEligiblePath(file, mode)),
(x) => x.count,
(x) => x.file
)
Expand Down
36 changes: 32 additions & 4 deletions src/core/indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ export interface IndexerOptions {
interface PersistedIndexingStats {
indexedFiles: number;
totalChunks: number;
searchableChunks?: number;
chunkLimitApplied?: boolean;
totalFiles: number;
generatedAt: string;
}
Expand Down Expand Up @@ -956,7 +958,9 @@ export class CodebaseIndexer {
let chunksToEmbed = chunksForEmbedding;
if (chunksForEmbedding.length > maxChunks) {
console.warn(
`WARNING: ${chunksForEmbedding.length} chunks exceed limit. Indexing first ${maxChunks} chunks.`
`WARNING: ${chunksForEmbedding.length} chunks exceed the searchable limit (${maxChunks}). ` +
`Only the first ${maxChunks} chunks are indexed for search. ` +
`Raise projects[].parsing.maxChunks in ~/.codebase-context/config.json for this repo root.`
);
chunksToEmbed = chunksForEmbedding.slice(0, maxChunks);
}
Expand Down Expand Up @@ -1098,8 +1102,10 @@ export class CodebaseIndexer {
// Keyword index always uses ALL chunks (full regen)
const indexPath = path.join(generationDirectory, KEYWORD_INDEX_FILENAME);
// Memory safety: cap keyword index too
const keywordChunks =
allChunks.length > maxChunks ? allChunks.slice(0, maxChunks) : allChunks;
const chunkLimitApplied = allChunks.length > maxChunks;
const keywordChunks = chunkLimitApplied ? allChunks.slice(0, maxChunks) : allChunks;
stats.searchableChunks = keywordChunks.length;
stats.chunkLimitApplied = chunkLimitApplied;
await fs.writeFile(
indexPath,
JSON.stringify({
Expand Down Expand Up @@ -1206,6 +1212,8 @@ export class CodebaseIndexer {
const persistedStats: PersistedIndexingStats = {
indexedFiles: stats.indexedFiles,
totalChunks: stats.totalChunks,
searchableChunks: stats.searchableChunks,
chunkLimitApplied: stats.chunkLimitApplied,
Comment on lines +1215 to +1216

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore the cap metadata on unchanged incremental runs

After indexing a capped repository, restarting without source changes takes the incremental short-circuit, whose persisted-stats reader restores only indexedFiles, totalChunks, and totalFiles. Although these new fields are written here, neither is restored, so formatIndexingCompleteLine falls back to reporting all analyzed chunks as indexed—for example, 88424 chunks instead of 5000/88424 chunks searchable. Restore searchableChunks and chunkLimitApplied in the no-change path so the new reporting remains accurate on subsequent starts.

Useful? React with 👍 / 👎.

Comment on lines +1215 to +1216

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Cap details disappear on reuse

The new cap fields are saved but not restored when indexing finds no changed files. That branch restores only indexedFiles, totalChunks, and totalFiles, then returns. After a capped build, an unchanged run loses the searchable count and prints the analyzed total without the cap warning. Restore both fields from the saved stats and test a capped build followed by an unchanged run.

totalFiles: stats.totalFiles,
generatedAt
};
Expand Down Expand Up @@ -1264,7 +1272,7 @@ export class CodebaseIndexer {
);
} else {
console.error(`Indexing complete in ${stats.duration}ms`);
console.error(`Indexed ${stats.indexedFiles} files, ${stats.totalChunks} chunks`);
console.error(formatIndexingProgressSummary(stats));
}

return stats;
Expand Down Expand Up @@ -1589,3 +1597,23 @@ export class CodebaseIndexer {
return { ...this.progress };
}
}

export function formatIndexingProgressSummary(stats: IndexingStats): string {
const searchable = stats.searchableChunks ?? stats.totalChunks;
if (stats.chunkLimitApplied && searchable < stats.totalChunks) {
return (
`Indexed ${stats.indexedFiles} files, ${stats.totalChunks} chunks analyzed, ` +
`${searchable} chunks searchable (limit applied)`
);
}
return `Indexed ${stats.indexedFiles} files, ${stats.totalChunks} chunks`;
}

export function formatIndexingCompleteLine(stats: IndexingStats): string {
const searchable = stats.searchableChunks ?? stats.totalChunks;
const durationSec = (stats.duration / 1000).toFixed(2);
if (stats.chunkLimitApplied && searchable < stats.totalChunks) {
return `Complete: ${stats.indexedFiles} files, ${searchable}/${stats.totalChunks} chunks searchable in ${durationSec}s`;
}
return `Complete: ${stats.indexedFiles} files, ${stats.totalChunks} chunks in ${durationSec}s`;
}
75 changes: 71 additions & 4 deletions src/core/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,24 @@ import {
VECTOR_DB_DIRNAME
} from '../constants/codebase-context.js';

/** File/path-like tokens in a natural-language query (e.g. NOW.md, check-agent-instructions.mjs). */
export function extractQueryPathTokens(query: string): string[] {
const tokens = new Set<string>();
for (const match of query.matchAll(/\b([A-Za-z0-9][A-Za-z0-9._-]*\.(?:md|mdx|txt))\b/g)) {
tokens.add(match[1].toLowerCase());
}
for (const match of query.matchAll(/\b([a-z][a-z0-9-]*(?:-[a-z0-9]+)+)\.(?:mjs|cjs|js|ts)\b/gi)) {
tokens.add(match[0].toLowerCase());
tokens.add(match[1].toLowerCase());
}
for (const match of query.matchAll(/\b([a-z][a-z0-9-]*(?:-[a-z0-9]+){2,})\b/gi)) {
if (match[1].length >= 12) {
tokens.add(match[1].toLowerCase());
}
}
return [...tokens];
}

export interface SearchOptions {
useSemanticSearch?: boolean;
useKeywordSearch?: boolean;
Expand Down Expand Up @@ -1941,11 +1959,23 @@ export class CodebaseSearcher {
}

if (fuseResults.length === 0 || (fuseResults[0].score ?? 0) > KEYWORD_FUSE_OPTIONS.threshold!) {
const rescue = await this.rescueKeywordMatches(query, limit, filters, validation);
const seen = new Set(rescue.map(({ item }) => item.id));
fuseResults = [...rescue, ...fuseResults.filter(({ item }) => !seen.has(item.id))].sort(
(a, b) => (a.score ?? 0) - (b.score ?? 0)
const pathRescue: FuseResult<CodeChunk>[] = this.matchChunksByPathTokens(query, filters).map(
(chunk, refIndex) => ({
item: chunk,
refIndex,
score: 0
})
Comment on lines +1962 to +1967

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Loose paths hide exact files

Every path match gets score: 0, including loose substring matches. A query naming NOW.md also matches archive/now.md.bak through includes(token), giving both the maximum keyword score. Matches keep index order, and the candidate limit applies before the later boosts. Enough loose matches can exclude NOW.md entirely. Rank exact filenames above stem and substring matches before applying the limit.

);
const rescue = await this.rescueKeywordMatches(query, limit, filters, validation);
const seen = new Set([
...pathRescue.map(({ item }) => item.id),
...rescue.map(({ item }) => item.id)
]);
fuseResults = [
...pathRescue,
...rescue,
...fuseResults.filter(({ item }) => !seen.has(item.id))
Comment on lines +1974 to +1977

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Deduplicate the two rescue lists before merging candidates

When a filename-bearing query triggers rescue and a chunk matches both its path token and the lexical rescue criteria, that chunk appears in both pathRescue and rescue. The seen set only filters the original Fuse results, so the duplicate survives selectCurrentCandidates, consumes two candidate slots, and causes collectHybridMatches to append two keyword ranks for the same query variant. fuseAndScoreResults then sums both contributions, artificially inflating relevance and confidence. Deduplicate the rescue lists against each other before candidate selection.

Useful? React with 👍 / 👎.

].sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
Comment on lines +1974 to +1978

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Repeated chunks skew search

pathRescue and rescue can contain the same chunk, but seen only removes duplicates from the trailing Fuse results. A natural-language script query can match both the script's path and multiple query words. The repeated chunk consumes candidate slots and gets two contributions to its final search score. Deduplicate the combined lists by item.id before selecting candidates.

}
const current = await this.selectCurrentCandidates(
fuseResults,
Expand Down Expand Up @@ -1984,6 +2014,20 @@ export class CodebaseSearcher {
score = Math.min(1.0, score + 0.1);
}

for (const token of extractQueryPathTokens(query)) {
const base = path.basename(relativePathLower);
const stem = base.replace(/\.[^.]+$/, '');
if (
base === token ||
relativePathLower.endsWith(`/${token}`) ||
stem === token.replace(/\.[^.]+$/, '')
) {
score = Math.min(1.0, score + 0.45);
} else if (stem.includes(token) || relativePathLower.includes(token)) {
score = Math.min(1.0, score + 0.25);
Comment on lines +2017 to +2027

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Search weights lack a reason

The new ranking adds 0.45 for exact path matches and 0.25 for partial matches without explaining the weights or how repeated tokens should affect the score. The repository requires every ranking heuristic to include research citations or a principled justification. Before merging, document the reason for these boosts, including their interaction with the score cap and the full-name/stem tokens.

Context Used: AGENTS.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

}
}

return {
chunk,
score
Expand All @@ -1996,6 +2040,29 @@ export class CodebaseSearcher {
);
}

private matchChunksByPathTokens(query: string, filters?: SearchFilters): CodeChunk[] {
const tokens = extractQueryPathTokens(query);
if (tokens.length === 0) {
return [];
}
return this.chunks.filter((item) => {
if (!this.matchesSearchFilters(item, filters)) {
return false;
}
const relativePathLower = item.relativePath.toLowerCase();
const base = path.basename(relativePathLower);
const stem = base.replace(/\.[^.]+$/, '');
return tokens.some(
(token) =>
base === token ||
relativePathLower.endsWith(`/${token}`) ||
stem === token.replace(/\.[^.]+$/, '') ||
stem.includes(token) ||
relativePathLower.includes(token)
);
});
}

private generateRelevanceReason(chunk: CodeChunk, query: string): string {
const reasons: string[] = [];

Expand Down
8 changes: 2 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { fileURLToPath } from 'url';
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
import type { ProjectConfig } from './server/config.js';
import type { Resource } from '@modelcontextprotocol/sdk/types.js';
import { CodebaseIndexer } from './core/indexer.js';
import { CodebaseIndexer, formatIndexingCompleteLine } from './core/indexer.js';

import { analyzerRegistry } from './core/analyzer-registry.js';
import { AngularAnalyzer } from './analyzers/angular/index.js';
Expand Down Expand Up @@ -1389,11 +1389,7 @@ async function performIndexingOnce(
project.indexState.lastIndexed = new Date();
project.indexState.stats = stats;

console.error(
`Complete: ${stats.indexedFiles} files, ${stats.totalChunks} chunks in ${(
stats.duration / 1000
).toFixed(2)}s`
);
console.error(formatIndexingCompleteLine(stats));

// Auto-extract memories from git history (non-blocking, best-effort)
try {
Expand Down
4 changes: 4 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,10 @@ export interface IndexingStats {
indexedFiles: number;
skippedFiles: number;
totalChunks: number;
/** Chunks written to the keyword index (may be lower than totalChunks when capped). */
searchableChunks?: number;
/** True when parsing.maxChunks (default 5000) truncated the searchable index. */
chunkLimitApplied?: boolean;
totalLines: number;
duration: number; // milliseconds
avgChunkSize: number;
Expand Down
27 changes: 27 additions & 0 deletions tests/codebase-map.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,33 @@ describe('buildCodebaseMap', () => {
expect(map.architecture.entrypoints).toEqual(['src/cli.ts', 'src/index.ts']);
});

it('excludes vendored raw bundles from bounded map entrypoints and layers', async () => {
const rootPath = await createTempMapProject({
graph: {
imports: {
'live-from-ai/raw/index-BMtpUqT3.js': ['live-from-ai/raw/vendor.js'],
'NOW.md': [],
'scripts/context-route.mjs': ['NOW.md']
},
importedBy: {
'live-from-ai/raw/index-BMtpUqT3.js': [],
'NOW.md': ['scripts/context-route.mjs'],
'scripts/context-route.mjs': []
},
exports: {}
}
});
try {
const project = createProjectState(rootPath);
const map = await buildCodebaseMap(project);
expect(map.architecture.layers.map((layer) => layer.name)).not.toContain('live-from-ai');
expect(map.architecture.entrypoints.some((ep) => ep.includes('index-BMtpUqT3'))).toBe(false);
expect(map.architecture.entrypoints).toContain('scripts/context-route.mjs');
} finally {
await removeTempMapProject(rootPath);
}
});

it('derives hub files: top 5 by importedBy count, sorted count-desc then alpha', async () => {
const project = createProjectState(FIXTURE_ROOT);
const map = await buildCodebaseMap(project, { mode: 'full' });
Expand Down
51 changes: 51 additions & 0 deletions tests/indexer-chunk-limit-reporting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import {
formatIndexingCompleteLine,
formatIndexingProgressSummary
} from '../src/core/indexer.js';
import type { IndexingStats } from '../src/types/index.js';

function baseStats(overrides: Partial<IndexingStats> = {}): IndexingStats {
return {
totalFiles: 100,
indexedFiles: 90,
skippedFiles: 10,
totalChunks: 88424,
searchableChunks: 5000,
chunkLimitApplied: true,
totalLines: 1,
duration: 2500,
avgChunkSize: 100,
componentsByType: {},
componentsByLayer: {
core: 0,
feature: 0,
shared: 0,
infrastructure: 0,
unknown: 0
},
errors: [],
startedAt: new Date(),
...overrides
};
}

describe('indexer chunk limit reporting', () => {
it('formatIndexingProgressSummary distinguishes analyzed vs searchable counts', () => {
const line = formatIndexingProgressSummary(baseStats());
expect(line).toContain('88424 chunks analyzed');
expect(line).toContain('5000 chunks searchable');
});

it('formatIndexingCompleteLine shows searchable fraction when capped', () => {
const line = formatIndexingCompleteLine(baseStats());
expect(line).toBe('Complete: 90 files, 5000/88424 chunks searchable in 2.50s');
});

it('formatIndexingCompleteLine stays compact when no cap applied', () => {
const line = formatIndexingCompleteLine(
baseStats({ totalChunks: 120, searchableChunks: 120, chunkLimitApplied: false })
);
expect(line).toBe('Complete: 90 files, 120 chunks in 2.50s');
});
});
23 changes: 23 additions & 0 deletions tests/search-path-token-boost.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { extractQueryPathTokens } from '../src/core/search.js';

describe('extractQueryPathTokens', () => {
it('extracts markdown filenames from natural-language queries', () => {
const tokens = extractQueryPathTokens(
'NOW.md live root entrypoint current state for routing'
);
expect(tokens).toContain('now.md');
});

it('extracts script paths and hyphenated stems', () => {
const tokens = extractQueryPathTokens(
'what must I check before editing AGENTS.md protected agent instructions'
);
expect(tokens).toContain('agents.md');
});

it('extracts explicit script module names', () => {
const tokens = extractQueryPathTokens('run check-agent-instructions before commit');
expect(tokens).toContain('check-agent-instructions');
});
});
Loading