Skip to content

Commit 396dd66

Browse files
committed
fix: resolve PR #98 review blockers
1 parent 88aa0bd commit 396dd66

5 files changed

Lines changed: 138 additions & 96 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,37 +2,28 @@
22

33
## Unreleased
44

5-
## [2.1.0](https://github.com/PatrickSys/codebase-context/compare/v2.0.0...v2.1.0) (2026-04-13)
5+
## [2.1.0](https://github.com/PatrickSys/codebase-context/compare/v1.9.0...v2.1.0) (2026-04-13)
66

77
### Features
88

99
- **search:** surface chunk intelligence directly in `search_codebase` results, including symbol identity, scope, signature preview, and compact/full response budgeting
1010
- **map:** upgrade the conventions map with structural skeleton sections and add `map --export` so the compact map can be written to `CODEBASE_MAP.md`
11+
- **mcp:** rework multi-project routing so one MCP server can serve multiple projects instead of one hardcoded server entry per repo
12+
- **mcp:** keep explicit `project` as the fallback when the client does not provide enough project context
13+
- **mcp:** accept repo paths, subproject paths, and file paths as `project` selectors when routing is ambiguous
1114

1215
### Bug Fixes
1316

1417
- **metadata:** require real dependency evidence plus multiple framework indicators before labeling a repo as Next.js or another specialized framework
1518
- **reranker:** auto-heal corrupted cross-encoder cache entries and surface degraded reranker state in `searchQuality.rerankerStatus`
1619
- **benchmarks:** harden comparator lanes for cross-platform execution and keep setup failures explicit instead of silently turning them into claims
20+
- **search:** auto-heal on corrupted index now triggers a background rebuild instead of blocking the search response
1721

1822
### Documentation
1923

2024
- publish the v2.1.0 discovery benchmark rerun with the current gate output: `pending_evidence`, `claimAllowed: false`, `24` frozen tasks, `0.75` average usefulness, and `1822.25` average estimated tokens
2125
- document the current comparator truth instead of stale assumptions: the public proof still has setup failures plus near-empty comparator outputs on this host, so benchmark win claims remain blocked
2226
- note the new `searchQuality.tokenEstimate` advisory contract: estimates are based on the final serialized response payload and warnings only appear above the 4K-token threshold
23-
24-
### Features
25-
26-
- **mcp:** rework multi-project routing so one MCP server can serve multiple projects instead of one hardcoded server entry per repo
27-
- **mcp:** keep explicit `project` as the fallback when the client does not provide enough project context
28-
- **mcp:** accept repo paths, subproject paths, and file paths as `project` selectors when routing is ambiguous
29-
30-
### Bug Fixes
31-
32-
- **search:** auto-heal on corrupted index now triggers a background rebuild instead of blocking the search response
33-
34-
### Documentation
35-
3627
- simplify the setup story around a roots-first contract: roots-capable multi-project sessions, single-project fallback, and explicit `project` retries
3728
- clarify that issue #63 fixed the architecture and workspace-aware workflow, but issue #2 is still only partially solved when the client does not provide roots or active-project context
3829
- remove the repo-local `init` / marker-file story from the public setup guidance

src/index.ts

Lines changed: 11 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
} from './utils/project-discovery.js';
5151
import { readIndexMeta, validateIndexArtifacts } from './core/index-meta.js';
5252
import { TOOLS, dispatchTool, type ToolContext, type ToolResponse } from './tools/index.js';
53+
import { finalizeSearchPayloadText } from './tools/search-payload-budget.js';
5354
import type { ProjectDescriptor, ToolPaths } from './tools/types.js';
5455
import {
5556
getOrCreateProject,
@@ -119,48 +120,20 @@ type ProjectResolution =
119120
| { ok: true; project: ProjectState }
120121
| { ok: false; response: ToolResponse };
121122

122-
function isPlainRecord(value: unknown): value is Record<string, unknown> {
123-
return typeof value === 'object' && value !== null && !Array.isArray(value);
124-
}
125-
126123
function finalizeJsonTextPayload(payload: Record<string, unknown>): string {
127-
if (!isPlainRecord(payload.searchQuality)) {
124+
const mode =
125+
typeof payload.budget === 'object' &&
126+
payload.budget !== null &&
127+
'mode' in payload.budget &&
128+
(payload.budget.mode === 'compact' || payload.budget.mode === 'full')
129+
? payload.budget.mode
130+
: undefined;
131+
132+
if (!mode) {
128133
return JSON.stringify(payload);
129134
}
130135

131-
let tokenEstimate =
132-
typeof payload.searchQuality.tokenEstimate === 'number'
133-
? payload.searchQuality.tokenEstimate
134-
: 0;
135-
let warning =
136-
typeof payload.searchQuality.warning === 'string' ? payload.searchQuality.warning : undefined;
137-
let renderedPayload = '';
138-
139-
for (let attempt = 0; attempt < 5; attempt += 1) {
140-
renderedPayload = JSON.stringify({
141-
...payload,
142-
searchQuality: {
143-
...payload.searchQuality,
144-
...(warning ? { warning } : {}),
145-
tokenEstimate
146-
}
147-
});
148-
149-
const nextTokenEstimate = Math.ceil(renderedPayload.length / 4);
150-
const nextWarning =
151-
nextTokenEstimate > 4000
152-
? `Large search payload: estimated ${nextTokenEstimate} tokens. Prefer compact mode or tighter filters before pasting into an agent.`
153-
: undefined;
154-
155-
if (nextTokenEstimate === tokenEstimate && nextWarning === warning) {
156-
return renderedPayload;
157-
}
158-
159-
tokenEstimate = nextTokenEstimate;
160-
warning = nextWarning;
161-
}
162-
163-
return renderedPayload;
136+
return finalizeSearchPayloadText(payload, { mode });
164137
}
165138

166139
function registerKnownRoot(rootPath: string): string {

src/tools/search-codebase.ts

Lines changed: 11 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import type { MemoryWithConfidence } from '../memory/store.js';
2626
import { InternalFileGraph } from '../utils/usage-tracker.js';
2727
import type { FileExport } from '../utils/usage-tracker.js';
2828
import { RELATIONSHIPS_FILENAME } from '../constants/codebase-context.js';
29+
import { finalizeSearchPayloadText } from './search-payload-budget.js';
2930

3031
// Stop words for compact-mode memory relevance filter (mirrors QUERY_STOP_WORDS in search.ts)
3132
const COMPACT_STOP_WORDS = new Set([
@@ -1061,44 +1062,6 @@ export async function handle(
10611062
relatedMemories?: string[];
10621063
};
10631064

1064-
function renderSearchPayloadText(payload: SearchResponsePayload): string {
1065-
let tokenEstimate = 0;
1066-
let warning: string | undefined;
1067-
let renderedPayload = '';
1068-
1069-
for (let attempt = 0; attempt < 5; attempt += 1) {
1070-
renderedPayload = JSON.stringify(
1071-
{
1072-
...payload,
1073-
searchQuality: {
1074-
...searchQualityBlock,
1075-
...(warning && { warning }),
1076-
tokenEstimate
1077-
}
1078-
},
1079-
null,
1080-
2
1081-
);
1082-
1083-
const estimatedTransportPayload =
1084-
process.platform === 'win32' ? renderedPayload.replace(/\n/g, '\r\n') : renderedPayload;
1085-
const nextTokenEstimate = Math.ceil(estimatedTransportPayload.length / 4);
1086-
const nextWarning =
1087-
nextTokenEstimate > 4000
1088-
? `Large search payload: estimated ${nextTokenEstimate} tokens. Prefer compact mode or tighter filters before pasting into an agent.`
1089-
: undefined;
1090-
1091-
if (nextTokenEstimate === tokenEstimate && nextWarning === warning) {
1092-
return renderedPayload;
1093-
}
1094-
1095-
tokenEstimate = nextTokenEstimate;
1096-
warning = nextWarning;
1097-
}
1098-
1099-
return renderedPayload;
1100-
}
1101-
11021065
// Compact mode (default): bounded response with light graph context
11031066
const isCompact = mode !== 'full';
11041067

@@ -1108,7 +1071,8 @@ export async function handle(
11081071
const patternSummary = buildPatternSummary();
11091072
const bestExample = getBestExample(compactResults);
11101073
const nextHops = buildNextHops(compactResults, searchQuality);
1111-
const payloadText = renderSearchPayloadText({
1074+
const payloadText = finalizeSearchPayloadText(
1075+
{
11121076
status: 'success',
11131077
searchQuality: searchQualityBlock,
11141078
budget: { mode: 'compact', resultCount: compactResults.length },
@@ -1152,7 +1116,9 @@ export async function handle(
11521116
...(strongMemories.length > 0 && {
11531117
relatedMemories: strongMemories.map((m) => `${m.memory} (${m.effectiveConfidence})`)
11541118
})
1155-
});
1119+
},
1120+
{ mode: 'compact', pretty: true, transportAware: true }
1121+
);
11561122

11571123
return {
11581124
content: [
@@ -1165,7 +1131,8 @@ export async function handle(
11651131
}
11661132

11671133
// Full mode: today's response shape + budget + relevanceReason; consumers removed
1168-
const payloadText = renderSearchPayloadText({
1134+
const payloadText = finalizeSearchPayloadText(
1135+
{
11691136
status: 'success',
11701137
searchQuality: searchQualityBlock,
11711138
budget: { mode: 'full', resultCount: results.length },
@@ -1212,7 +1179,9 @@ export async function handle(
12121179
.slice(0, 3)
12131180
.map((m) => `${m.memory} (${m.effectiveConfidence})`)
12141181
})
1215-
});
1182+
},
1183+
{ mode: 'full', pretty: true, transportAware: true }
1184+
);
12161185

12171186
return {
12181187
content: [

src/tools/search-payload-budget.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
type SearchPayloadMode = 'compact' | 'full';
2+
3+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
4+
return typeof value === 'object' && value !== null && !Array.isArray(value);
5+
}
6+
7+
function buildWarning(tokenEstimate: number, mode: SearchPayloadMode): string | undefined {
8+
if (tokenEstimate <= 4000) {
9+
return undefined;
10+
}
11+
12+
if (mode === 'compact') {
13+
return `Large search payload: estimated ${tokenEstimate} tokens. Try tighter filters (e.g. layer=, language=) to reduce payload size.`;
14+
}
15+
16+
return `Large search payload: estimated ${tokenEstimate} tokens. Prefer compact mode or tighter filters before pasting into an agent.`;
17+
}
18+
19+
export function finalizeSearchPayloadText(
20+
payload: Record<string, unknown>,
21+
options: {
22+
mode: SearchPayloadMode;
23+
pretty?: boolean;
24+
transportAware?: boolean;
25+
}
26+
): string {
27+
if (!isPlainRecord(payload.searchQuality)) {
28+
return JSON.stringify(payload, null, options.pretty ? 2 : undefined);
29+
}
30+
31+
let tokenEstimate =
32+
typeof payload.searchQuality.tokenEstimate === 'number'
33+
? payload.searchQuality.tokenEstimate
34+
: 0;
35+
let warning =
36+
typeof payload.searchQuality.warning === 'string' ? payload.searchQuality.warning : undefined;
37+
let renderedPayload = '';
38+
39+
for (let attempt = 0; attempt < 5; attempt += 1) {
40+
renderedPayload = JSON.stringify(
41+
{
42+
...payload,
43+
searchQuality: {
44+
...payload.searchQuality,
45+
...(warning ? { warning } : {}),
46+
tokenEstimate
47+
}
48+
},
49+
null,
50+
options.pretty ? 2 : undefined
51+
);
52+
53+
const estimatedTransportPayload =
54+
options.transportAware && process.platform === 'win32'
55+
? renderedPayload.replace(/\n/g, '\r\n')
56+
: renderedPayload;
57+
const nextTokenEstimate = Math.ceil(estimatedTransportPayload.length / 4);
58+
const nextWarning = buildWarning(nextTokenEstimate, options.mode);
59+
60+
if (nextTokenEstimate === tokenEstimate && nextWarning === warning) {
61+
return renderedPayload;
62+
}
63+
64+
tokenEstimate = nextTokenEstimate;
65+
warning = nextWarning;
66+
}
67+
68+
return renderedPayload;
69+
}

tests/search-compact-mode.test.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,46 @@ describe('search_codebase compact/full mode', () => {
363363
expect(payload.searchQuality.warning).toBeUndefined();
364364
});
365365

366+
it('uses filter-only guidance when a final compact payload exceeds the token threshold', async () => {
367+
const oversizedSummary = 'Token-heavy compact summary '.repeat(1200);
368+
searchMocks.search.mockResolvedValueOnce([
369+
makeResult({
370+
summary: oversizedSummary
371+
})
372+
]);
373+
374+
const { server } = await import('../src/index.js');
375+
const handler = (
376+
server as {
377+
_requestHandlers?: Map<
378+
string,
379+
(r: unknown) => Promise<{ content: Array<{ type: string; text: string }> }>
380+
>;
381+
}
382+
)._requestHandlers?.get('tools/call');
383+
if (!handler) throw new Error('Expected tools/call handler');
384+
385+
const response = await handler({
386+
jsonrpc: '2.0',
387+
id: 1,
388+
method: 'tools/call',
389+
params: { name: 'search_codebase', arguments: { query: 'auth service' } }
390+
});
391+
392+
const payload = JSON.parse(response.content[0].text) as {
393+
searchQuality: {
394+
tokenEstimate: number;
395+
warning?: string;
396+
};
397+
};
398+
399+
expect(payload.searchQuality.tokenEstimate).toBe(Math.ceil(response.content[0].text.length / 4));
400+
expect(payload.searchQuality.tokenEstimate).toBeGreaterThan(4000);
401+
expect(payload.searchQuality.warning).toBe(
402+
`Large search payload: estimated ${payload.searchQuality.tokenEstimate} tokens. Try tighter filters (e.g. layer=, language=) to reduce payload size.`
403+
);
404+
});
405+
366406
// Test 5: Full mode returns hints arrays and all memories + budget
367407
it('full mode returns hints object with callers/tests and budget metadata', async () => {
368408
searchMocks.search.mockResolvedValueOnce([makeResult()]);
@@ -444,8 +484,8 @@ describe('search_codebase compact/full mode', () => {
444484

445485
expect(payload.searchQuality.tokenEstimate).toBe(Math.ceil(response.content[0].text.length / 4));
446486
expect(payload.searchQuality.tokenEstimate).toBeGreaterThan(4000);
447-
expect(payload.searchQuality.warning).toContain(
448-
`estimated ${payload.searchQuality.tokenEstimate} tokens`
487+
expect(payload.searchQuality.warning).toBe(
488+
`Large search payload: estimated ${payload.searchQuality.tokenEstimate} tokens. Prefer compact mode or tighter filters before pasting into an agent.`
449489
);
450490
});
451491

0 commit comments

Comments
 (0)